Automated Arch binary builds of JASP for the AUR
Build JASP binary and publish to AUR / check (push) Successful in 10s
Build JASP binary and publish to AUR / build (push) Failing after 12s

Upstream JASP ships no Linux binary, so the existing AUR package builds
everything from source including several hundred R packages. This builds
that once in CI and publishes the result as jasp-desktop-bin.

JASP links against the system R and Qt6 rather than bundling them, so a
rebuild is triggered by an Arch r/qt6-base bump as well as by a new JASP
release, and the generated PKGBUILD pins depends to the versions actually
built against.
This commit is contained in:
agw
2026-09-01 20:30:00 +02:00
commit 0d4db32d5c
11 changed files with 827 additions and 0 deletions
+82
View File
@@ -0,0 +1,82 @@
#!/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.
Writes GITHUB_OUTPUT-style key=value pairs to stdout (and to $GITHUB_OUTPUT).
"""
import json
import os
import sys
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"
def fetch_json(url: str) -> dict:
req = urllib.request.Request(url, headers={"User-Agent": "jasp-desktop-bin-ci"})
with urllib.request.urlopen(req, timeout=30) as r:
return json.load(r)
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"]
def minor(version: str) -> str:
return ".".join(version.split(".")[:2])
def main() -> int:
forced = os.environ.get("FORCE_BUILD", "").lower() == "true"
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"))
try:
state = json.loads(open(STATE_FILE).read())
except FileNotFoundError:
state = {}
reasons = []
if forced:
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}")
needs_build = bool(reasons)
print(f"jasp_tag = {jasp_tag}")
print(f"r_minor = {r_minor}")
print(f"qt6_minor = {qt_minor}")
print(f"decision = {'BUILD' if needs_build else 'up to date'}")
for reason in reasons:
print(f" reason: {reason}")
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"r_minor={r_minor}\n")
fh.write(f"qt6_minor={qt_minor}\n")
fh.write(f"reasons={'; '.join(reasons)}\n")
return 0
if __name__ == "__main__":
sys.exit(main())
+104
View File
@@ -0,0 +1,104 @@
#!/usr/bin/env python3
"""Turn build-info.json into a ready-to-publish PKGBUILD.
pkgrel is derived from what is currently on the AUR: same pkgver means this is a
rebuild (against a new R/Qt6), so pkgrel increments; a new pkgver resets it to 1.
"""
import argparse
import json
import re
import sys
import urllib.request
AUR_RPC = "https://aur.archlinux.org/rpc/v5/info?arg[]={}"
def next_minor(version: str) -> str:
"""4.6 -> 4.7, 6.11 -> 6.12 — the upper bound of the compatible range."""
major, minor = version.split(".")[:2]
return f"{major}.{int(minor) + 1}"
def current_aur_version(pkgname: str) -> tuple[str, int] | None:
"""(pkgver, pkgrel) currently published on the AUR, or None if absent."""
try:
with urllib.request.urlopen(AUR_RPC.format(pkgname), timeout=30) as r:
results = json.load(r).get("results", [])
except Exception as exc: # network trouble must not silently reset pkgrel
print(f"warning: could not query AUR: {exc}", file=sys.stderr)
return None
if not results:
return None
full = results[0]["Version"] # e.g. "0.98.1-2"
ver, _, rel = full.rpartition("-")
return ver, int(rel) if rel.isdigit() else 1
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--build-info", required=True)
ap.add_argument("--template", required=True)
ap.add_argument("--out", required=True)
ap.add_argument("--gh-owner", required=True)
ap.add_argument("--gh-repo", default="jasp-desktop-bin")
ap.add_argument("--maintainer", required=True)
ap.add_argument("--pkgname", default="jasp-desktop-bin")
args = ap.parse_args()
info = json.loads(open(args.build_info).read())
jasp_version = info["jasp_version"]
published = current_aur_version(args.pkgname)
if published and published[0] == jasp_version:
pkgrel = published[1] + 1
print(f"rebuild of {jasp_version}: pkgrel {published[1]} -> {pkgrel}")
else:
pkgrel = 1
print(f"new version {jasp_version}: pkgrel 1")
tag = f"v{jasp_version}-{pkgrel}"
download_url = (
f"https://github.com/{args.gh_owner}/{args.gh_repo}"
f"/releases/download/{tag}/{info['tarball']}"
)
r_minor = info["r_minor"]
qt_minor = info["qt6_minor"]
substitutions = {
"@MAINTAINER@": args.maintainer,
"@JASP_VERSION@": jasp_version,
"@PKGREL@": str(pkgrel),
"@R_MINOR@": r_minor,
"@R_NEXT_MINOR@": next_minor(r_minor),
"@QT_MINOR@": qt_minor,
"@QT_NEXT_MINOR@": next_minor(qt_minor),
"@DOWNLOAD_URL@": download_url,
"@SHA256@": info["sha256"],
}
text = open(args.template).read()
for key, value in substitutions.items():
text = text.replace(key, value)
leftover = re.findall(r"@[A-Z0-9_]+@", text)
if leftover:
print(f"error: unsubstituted placeholders: {sorted(set(leftover))}", file=sys.stderr)
return 1
with open(args.out, "w") as fh:
fh.write(text)
# Consumed by the release + AUR-push steps of the workflow.
with open("release-meta.json", "w") as fh:
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)}")
return 0
if __name__ == "__main__":
sys.exit(main())
+63
View File
@@ -0,0 +1,63 @@
#!/bin/bash
# Create (or reuse) a GitHub release and upload the JASP tarball to it.
# Uses the REST API directly so the workflow does not depend on `gh` being
# present in the runner image.
#
# GH_TOKEN PAT with `contents: write` on the target repo (required)
# GH_OWNER GitHub account owning the release repo (required)
# GH_REPO release repo name (default jasp-desktop-bin)
# TAG release tag, e.g. v0.98.1-1 (required)
# ASSET path to the tarball to upload (required)
set -euo pipefail
: "${GH_TOKEN:?GH_TOKEN is required}"
: "${GH_OWNER:?GH_OWNER is required}"
: "${TAG:?TAG is required}"
: "${ASSET:?ASSET is required}"
GH_REPO="${GH_REPO:-jasp-desktop-bin}"
API="https://api.github.com/repos/${GH_OWNER}/${GH_REPO}"
UPLOADS="https://uploads.github.com/repos/${GH_OWNER}/${GH_REPO}"
AUTH=(-H "Authorization: Bearer ${GH_TOKEN}" -H "Accept: application/vnd.github+json")
asset_name="$(basename "$ASSET")"
echo ":: looking for existing release $TAG"
release_id="$(curl -sSf "${AUTH[@]}" "${API}/releases/tags/${TAG}" 2>/dev/null \
| python3 -c 'import json,sys; print(json.load(sys.stdin).get("id",""))' 2>/dev/null || true)"
if [[ -z "$release_id" ]]; then
echo ":: creating release $TAG"
release_id="$(curl -sSf -X POST "${AUTH[@]}" "${API}/releases" \
-d "$(python3 -c '
import json, os
print(json.dumps({
"tag_name": os.environ["TAG"],
"name": "JASP " + os.environ["TAG"],
"body": os.environ.get("RELEASE_NOTES", ""),
"draft": False,
"prerelease": False,
}))')" | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')"
else
echo ":: reusing release $release_id"
# Replace a same-named asset so re-runs are idempotent.
old="$(curl -sSf "${AUTH[@]}" "${API}/releases/${release_id}/assets" \
| python3 -c "
import json, sys
name = '${asset_name}'
print(next((a['id'] for a in json.load(sys.stdin) if a['name'] == name), ''))
")"
if [[ -n "$old" ]]; then
echo ":: deleting previous asset $old"
curl -sSf -X DELETE "${AUTH[@]}" "${API}/releases/assets/${old}"
fi
fi
echo ":: uploading $asset_name ($(du -h "$ASSET" | cut -f1))"
curl -sSf -X POST "${AUTH[@]}" \
-H "Content-Type: application/zstd" \
--data-binary "@${ASSET}" \
"${UPLOADS}/releases/${release_id}/assets?name=${asset_name}" >/dev/null
echo ":: uploaded to https://github.com/${GH_OWNER}/${GH_REPO}/releases/tag/${TAG}"
+50
View File
@@ -0,0 +1,50 @@
#!/bin/bash
# Publish the generated PKGBUILD to the AUR.
#
# AUR_SSH_KEY private key registered with aur.archlinux.org (required)
# $1 path to the generated PKGBUILD
#
# .SRCINFO is generated with makepkg inside an Arch container, because makepkg
# does not exist on the Ubuntu runner image.
set -euo pipefail
PKGBUILD_PATH="${1:?path to PKGBUILD is required}"
: "${AUR_SSH_KEY:?AUR_SSH_KEY is required}"
PKGNAME="${PKGNAME:-jasp-desktop-bin}"
workdir="$(mktemp -d)"
trap 'rm -rf "$workdir"' EXIT
install -m600 /dev/stdin "$workdir/aur_key" <<<"$AUR_SSH_KEY"
export GIT_SSH_COMMAND="ssh -i $workdir/aur_key -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new"
echo ":: cloning AUR repo $PKGNAME"
# A package that does not exist yet clones as an empty repo, which is fine.
git clone "ssh://aur@aur.archlinux.org/${PKGNAME}.git" "$workdir/aur"
cp "$PKGBUILD_PATH" "$workdir/aur/PKGBUILD"
echo ":: generating .SRCINFO"
docker run --rm -v "$workdir/aur:/pkg" -w /pkg archlinux:base-devel bash -euc '
useradd -m builder
chown -R builder /pkg
sudo -u builder makepkg --printsrcinfo > /pkg/.SRCINFO
'
cd "$workdir/aur"
git config user.name "${AUR_GIT_NAME:-JASP bin autobuilder}"
git config user.email "${AUR_GIT_EMAIL:-noreply@weircon.dk}"
if git diff --quiet && git diff --cached --quiet && [[ -z "$(git status --porcelain)" ]]; then
echo ":: AUR already up to date, nothing to push"
exit 0
fi
version="$(awk -F= '/^pkgver=/{v=$2} /^pkgrel=/{r=$2} END{print v"-"r}' PKGBUILD)"
git add PKGBUILD .SRCINFO
git commit -m "Update to ${version}"
git push origin HEAD:master
echo ":: pushed ${PKGNAME} ${version} to the AUR"
+45
View File
@@ -0,0 +1,45 @@
#!/bin/bash
# Commit state.json back to this repo so the next scheduled run knows what was
# already built. Without this the daily check would rebuild every night.
#
# GITEA_TOKEN token with write access to this repo (required)
set -euo pipefail
: "${GITEA_TOKEN:?GITEA_TOKEN is required}"
python3 - <<'PY'
import json
meta = json.load(open("release-meta.json"))
state = {
"jasp_tag": meta["jasp_tag"],
"jasp_version": meta["jasp_version"],
"pkgrel": meta["pkgrel"],
"r_minor": meta["r_minor"],
"qt6_minor": meta["qt6_minor"],
"sha256": meta["sha256"],
"download_url": meta["download_url"],
}
with open("state.json", "w") as fh:
json.dump(state, fh, indent=2)
fh.write("\n")
print(json.dumps(state, indent=2))
PY
if git diff --quiet -- state.json; then
echo ":: state.json unchanged"
exit 0
fi
git config user.name "jasp-bin autobuilder"
git config user.email "noreply@weircon.dk"
git add state.json
git commit -m "Record build of ${GITHUB_REF_NAME:-build}: $(python3 -c 'import json;print(json.load(open("state.json"))["jasp_version"])')"
origin="$(git remote get-url origin)"
host_path="${origin#*://}"
host_path="${host_path#*@}"
git push "https://x-access-token:${GITEA_TOKEN}@${host_path}" HEAD:"${GITHUB_REF_NAME:-master}"
echo ":: state recorded"