Bundle non-system libraries instead of version-pinning depends

Bundling R alone was not enough: R links libicuuc, and Arch moves ICU
regularly. The obvious fix - pinning 'icu>=78 <79' in depends - is worse
than the disease on a rolling distro, because pacman would then refuse to
upgrade ICU while JASP is installed, blocking the user's whole system
upgrade until a rebuild lands.

So bundle-libs.sh copies every non-system shared library into
/opt/jasp-desktop/lib and rewrites rpaths, by denylist rather than
allowlist. depends now carries no version constraints at all.

Qt6 stays system-provided on purpose - Qt is binary compatible across Qt 6
minors, and bundling QtWebEngine would add ~500 MB. That also means the
only rebuild triggers left are a new JASP release and a Qt major bump;
rebuilding for anything else would cost users a 1.7 GB re-download.

Also fixes check-upstream.py querying only repo=Extra, which silently
returned nothing for icu (it lives in core).
This commit is contained in:
agw
2026-09-01 22:05:00 +02:00
parent 7b39a5b06e
commit 56434ba7ed
7 changed files with 179 additions and 52 deletions
+24 -17
View File
@@ -1,10 +1,13 @@
#!/usr/bin/env python3
"""Decide whether a rebuild is needed.
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.
The build bundles its own R plus every non-system library it links against, so
Arch moving `r`, `icu`, `jsoncpp` and friends cannot affect an installed JASP and
must not trigger a rebuild — republishing costs every user a ~1.7 GB download.
What is left is Qt6, which stays a system dependency. Qt guarantees binary
compatibility across Qt 6 minor releases, so only a *major* Qt transition
warrants a rebuild.
Writes GITHUB_OUTPUT-style key=value pairs to stdout (and to $GITHUB_OUTPUT).
"""
@@ -16,7 +19,9 @@ import urllib.request
STATE_FILE = "state.json"
GH_LATEST = "https://api.github.com/repos/jasp-stats/jasp-desktop/releases/latest"
ARCH_PKG = "https://archlinux.org/packages/search/json/?name={}&repo=Extra"
# No repo filter: icu lives in core while qt6-base lives in extra, and pinning
# the wrong repo silently returns zero results and disables the trigger.
ARCH_PKG = "https://archlinux.org/packages/search/json/?name={}"
def fetch_json(url: str) -> dict:
@@ -27,9 +32,11 @@ def fetch_json(url: str) -> dict:
def arch_version(pkg: str) -> str:
results = fetch_json(ARCH_PKG.format(pkg)).get("results", [])
if not results:
raise RuntimeError(f"package {pkg} not found in Arch Extra")
return results[0]["pkgver"]
# Ignore staging/testing/kde-unstable: users are on the stable repos.
stable = [r for r in results if r["repo"] in ("core", "extra")]
if not stable:
raise RuntimeError(f"package {pkg} not found in Arch core/extra")
return stable[0]["pkgver"]
def minor(version: str) -> str:
@@ -41,8 +48,9 @@ def main() -> int:
pinned_tag = os.environ.get("JASP_TAG", "").strip()
jasp_tag = pinned_tag or fetch_json(GH_LATEST)["tag_name"]
qt_minor = minor(arch_version("qt6-base"))
icu_major = arch_version("icu").split(".")[0]
qt_version = arch_version("qt6-base")
qt_major = qt_version.split(".")[0]
qt_minor = minor(qt_version)
try:
state = json.loads(open(STATE_FILE).read())
@@ -54,27 +62,26 @@ 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("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}")
if state.get("qt6_major") != qt_major:
reasons.append(f"Qt major {state.get('qt6_major', 'none')} -> {qt_major}")
needs_build = bool(reasons)
print(f"jasp_tag = {jasp_tag}")
print(f"qt6_minor = {qt_minor}")
print(f"icu_major = {icu_major}")
print(f"qt = {qt_version} (major {qt_major})")
print(f"decision = {'BUILD' if needs_build else 'up to date'}")
for reason in reasons:
print(f" reason: {reason}")
if not needs_build:
print(" (Qt minor bumps are binary-compatible and everything else is bundled)")
out = os.environ.get("GITHUB_OUTPUT")
if out:
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"qt6_major={qt_major}\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
+2 -8
View File
@@ -64,17 +64,12 @@ def main() -> int:
)
qt_minor = info["qt6_minor"]
icu_major = info["icu_major"]
substitutions = {
"@MAINTAINER@": args.maintainer,
"@JASP_VERSION@": jasp_version,
"@PKGREL@": str(pkgrel),
"@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"],
}
@@ -96,9 +91,8 @@ 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 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}")
print(f" R bundled at {info['bundled_r_version']}; built against Qt {qt_minor}")
print(" depends are unpinned - everything volatile is bundled")
return 0
+1 -1
View File
@@ -18,7 +18,7 @@ state = {
"pkgrel": meta["pkgrel"],
"bundled_r_version": meta["bundled_r_version"],
"qt6_minor": meta["qt6_minor"],
"icu_major": meta["icu_major"],
"qt6_major": meta["qt6_major"],
"sha256": meta["sha256"],
"download_url": meta["download_url"],
}