Actually ship the R libraries the modules need

The first full build succeeded and produced a package that would not have
worked. Three separate causes, all silent:

- R was configured --with-blas --with-lapack, so it linked Arch's external
  BLAS and never built libRblas.so/libRlapack.so. The JASP module bundles
  expect those inside R_HOME, the way a conventional R and the Flatpak ship
  them. Now --without-blas, with a hard check after install.

- buildModuleBundlesLocally.R installs jaspModuleTools via install_github,
  which failed on anonymous GitHub rate limits. No bundles were produced at
  all. GITHUB_PAT is now required rather than merely recommended.

- buildAllDefaultJaspModules.R calls the bundle builder through system2(),
  which ignores exit status, so all of the above exited 0. Added explicit
  guards: jaspModuleTools must exist, Modules/local must be non-empty, and
  the staged tree must contain the bundles and all three R libraries.

Staging was also wrong: it copied ModuleBundleBuildDir/Modules, which is
cloned module *sources* with zero compiled objects, while dropping
LibraryR entirely - 267MB of R packages built against the bundled R.
This commit is contained in:
agw
2026-09-01 23:30:00 +02:00
parent 440cf8c475
commit 7dd99f0b7d
2 changed files with 281 additions and 10 deletions
+89 -10
View File
@@ -103,19 +103,38 @@ cd "R-${R_VERSION}"
# 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 \
--with-blas \
--with-lapack \
--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
@@ -156,15 +175,46 @@ 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, installed into the bundled R's library.
# 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"
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
MODULE_LIBRARY="$SRC_DIR/Tools/ModuleBundleBuildDir/LibraryR"
MODULE_BUNDLES="$SRC_DIR/Modules/local"
if [[ ! -d "$MODULE_LIBRARY/jaspModuleTools" ]]; then
echo "error: jaspModuleTools was not installed — install_github failed" >&2
echo " (GitHub rate limit, or GITHUB_PAT lacks read access)" >&2
exit 1
fi
bundle_count="$(find "$MODULE_BUNDLES" -mindepth 1 -maxdepth 1 2>/dev/null | wc -l)"
if [[ "$bundle_count" -eq 0 ]]; then
echo "error: the module build produced no bundles in ${MODULE_BUNDLES}" >&2
echo " system2() in buildAllDefaultJaspModules.R hides the real error;" >&2
echo " re-run buildModuleBundlesLocally.R by hand to see it" >&2
exit 1
fi
log "module build produced ${bundle_count} bundles"
# -------------------------------------------------------------------- staging
log "staging install tree"
rm -rf "$STAGE_DIR"
@@ -176,10 +226,25 @@ DESTDIR="$STAGE_DIR" ninja -C "$BUILD_DIR" install
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"
if [[ -d "$SRC_DIR/Tools/ModuleBundleBuildDir/Modules" ]]; then
cp -a "$SRC_DIR/Tools/ModuleBundleBuildDir/Modules/." "$STAGE_DIR$PREFIX/Modules/"
fi
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"
@@ -197,10 +262,24 @@ find . -maxdepth 2 \( -name 'COPYING*' -o -name 'LICENSE*' \) -type f \
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, BLAS/LAPACK ...). Qt6 stays a system dependency.
# 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}'; }
+192
View File
@@ -0,0 +1,192 @@
#!/usr/bin/env python3
"""Render the index page for https://asger.weirsoe.dk/tarballz/.
Reads the sidecar .json files that publish-tarball.sh writes next to each
tarball (on stdin, one JSON object per line) and emits a standalone HTML page.
The theme tokens are inlined rather than linked to the main site's stylesheet:
that file is content-hashed (_astro/_slug_.<hash>.css) and its name changes on
every site rebuild, so a link would silently rot. Inlining costs a few hundred
bytes and cannot break.
"""
import html
import json
import os
import sys
from datetime import datetime, timezone
GITEA_URL = "https://gitea.weircon.dk/agw/jasp-desktop-bin"
AUR_URL = "https://aur.archlinux.org/packages/jasp-desktop-bin"
PAGE = """<!doctype html>
<html lang="da" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Tarballs · Asger Weirsøe</title>
<meta name="description" content="Prækompilerede JASP-binærer til Arch Linux, bygget automatisk og udgivet på AUR som jasp-desktop-bin.">
<meta name="robots" content="noindex">
<link rel="icon" type="image/svg+xml" href="https://asger.weirsoe.dk/favicon.svg">
<script>
// Same localStorage key the main site uses, so the chosen theme carries over.
// Inline and before paint, otherwise the page flashes the wrong theme.
try {
var t = localStorage.getItem('theme');
if (!t) t = matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
document.documentElement.dataset.theme = t;
} catch (e) {}
</script>
<style>
:root, :root[data-theme=dark] {
color-scheme: dark;
--bg:#0b0f17; --surface:#121a27; --surface-2:#182232;
--border:#243044; --text:#e6e9ef; --muted:#97a3b6; --accent:#38bdf8;
}
:root[data-theme=light] {
color-scheme: light;
--bg:#f6f8fb; --surface:#fff; --surface-2:#eef2f8;
--border:#dde4ee; --text:#0f1729; --muted:#54617a; --accent:#0284c7;
}
:root[data-theme=terminal] {
color-scheme: dark;
--bg:#050a06; --surface:#0a140c; --surface-2:#102012;
--border:#1d3a22; --text:#4bff7e; --muted:#2fae57; --accent:#7dffa8;
}
* { box-sizing: border-box; }
body {
margin:0; background:var(--bg); color:var(--text);
font-family:"Inter Variable", ui-sans-serif, system-ui, -apple-system, sans-serif;
-webkit-font-smoothing:antialiased; line-height:1.6;
}
code, .mono { font-family:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; }
a { color:var(--accent); text-decoration:none; }
a:hover { text-decoration:underline; }
header { border-bottom:1px solid color-mix(in srgb, var(--border) 70%, transparent); }
.wrap { max-width:48rem; margin:0 auto; padding:0 1.25rem; }
header .wrap { display:flex; align-items:center; justify-content:space-between; padding-top:.875rem; padding-bottom:.875rem; }
.brand { font-weight:700; font-size:1.125rem; letter-spacing:-.025em; color:var(--text); }
.brand span { color:var(--accent); }
main { padding:3rem 0 1rem; }
h1 { font-size:2.25rem; font-weight:700; letter-spacing:-.025em; margin:0; }
.lede { color:var(--muted); margin-top:.75rem; }
.card { border:1px solid var(--border); border-radius:1rem; background:color-mix(in srgb, var(--surface) 60%, transparent); padding:1.25rem; }
.install { margin-top:1.75rem; }
.install code { display:block; background:var(--surface-2); border-radius:.55rem; padding:.6rem .8rem; font-size:.875rem; overflow-x:auto; }
h2 { font-size:1.25rem; font-weight:600; letter-spacing:-.025em; margin:2.5rem 0 1rem; }
ul.releases { list-style:none; margin:0; padding:0; display:grid; gap:.75rem; }
ul.releases li { border:1px solid var(--border); border-radius:1rem; background:color-mix(in srgb, var(--surface) 60%, transparent); padding:1rem 1.25rem; }
.row { display:flex; flex-wrap:wrap; align-items:baseline; gap:.6rem; justify-content:space-between; }
.ver { font-weight:600; font-size:1.05rem; }
.meta { color:var(--muted); font-size:.8rem; }
.sha { color:var(--muted); font-size:.75rem; word-break:break-all; margin-top:.5rem; }
.empty { color:var(--muted); }
footer { border-top:1px solid color-mix(in srgb, var(--border) 70%, transparent); margin-top:4rem; }
footer .wrap { padding-top:1.5rem; padding-bottom:2rem; color:color-mix(in srgb, var(--muted) 80%, transparent); font-size:.8rem; }
footer .note { font-size:.75rem; }
@media (max-width:480px) { h1 { font-size:1.75rem; } }
</style>
</head>
<body>
<header><div class="wrap">
<a class="brand" href="https://asger.weirsoe.dk/">Asger Weirsøe<span>.</span></a>
<nav class="meta"><a href="https://asger.weirsoe.dk/projekter">Projekter</a></nav>
</div></header>
<main class="wrap">
<h1>Tarballs</h1>
<p class="lede">
Prækompilerede <a href="https://jasp-stats.org/">JASP</a>-binærer til Arch Linux.
JASP udgiver kun installere til Windows og macOS, så disse er bygget fra
kildekoden af <a href="{gitea}">en automatisk pipeline</a> og udgivet på AUR som
<a href="{aur}">jasp-desktop-bin</a>.
</p>
<div class="install">
<p class="meta">Du behøver normalt ikke hente herfra manuelt — brug AUR:</p>
<code>yay -S jasp-desktop-bin</code>
</div>
<h2>Tilgængelige builds</h2>
{releases}
<p class="meta" style="margin-top:1.5rem">
Hver build indeholder sin egen R og de biblioteker den linker mod, så den ikke
bindes til bestemte versioner af noget på dit system.
Kun de nyeste {keep} builds gemmes.
</p>
</main>
<footer><div class="wrap">
<p class="note mono">bygget automatisk · self-hosted på proxmox · <a href="{gitea}">byggeopskrift</a></p>
<p>© {year} Asger Weirsøe</p>
</div></footer>
</body>
</html>
"""
def human_size(n: int) -> str:
for unit in ("B", "KB", "MB", "GB"):
if n < 1024 or unit == "GB":
return f"{n:.1f} {unit}".replace(".0 ", " ")
n /= 1024
return f"{n} B"
def main() -> int:
entries = []
for line in sys.stdin:
line = line.strip()
if not line:
continue
try:
entries.append(json.loads(line))
except json.JSONDecodeError:
print(f"warning: skipping unparsable sidecar line", file=sys.stderr)
# Newest first.
entries.sort(key=lambda e: e.get("built", ""), reverse=True)
if entries:
items = []
for e in entries:
name = html.escape(e["tarball"])
ver = html.escape(e.get("jasp_version", "?"))
built = e.get("built", "")
try:
built_h = datetime.fromisoformat(built).strftime("%d-%m-%Y")
except ValueError:
built_h = html.escape(built)
size = human_size(int(e.get("size_bytes", 0)))
sha = html.escape(e.get("sha256", ""))
r = html.escape(e.get("bundled_r_version", ""))
qt = html.escape(e.get("qt6_minor", ""))
items.append(
f' <li>\n'
f' <div class="row">\n'
f' <span class="ver">JASP {ver}</span>\n'
f' <a href="{name}" download>Hent · {size}</a>\n'
f' </div>\n'
f' <div class="meta">{built_h} · x86_64 · bundlet R {r} · bygget mod Qt {qt}</div>\n'
f' <div class="sha mono">sha256 {sha}</div>\n'
f' </li>'
)
releases = '<ul class="releases">\n' + "\n".join(items) + "\n </ul>"
else:
releases = '<p class="empty">Ingen builds tilgængelige lige nu.</p>'
sys.stdout.write(
PAGE.format(
releases=releases,
gitea=GITEA_URL,
aur=AUR_URL,
keep=len(entries) if entries else 3,
year=datetime.now(timezone.utc).year,
)
)
return 0
if __name__ == "__main__":
sys.exit(main())