commit 0d4db32d5c562a7285b6f77f7651f2323637f591 Author: agw Date: Tue Sep 1 20:30:00 2026 +0200 Automated Arch binary builds of JASP for the AUR 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. diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml new file mode 100644 index 0000000..c308774 --- /dev/null +++ b/.gitea/workflows/build.yml @@ -0,0 +1,131 @@ +name: Build JASP binary and publish to AUR + +# JASP publishes no Linux binary of its own, so this builds one on Arch and +# ships it as jasp-desktop-bin. Because JASP links against the system R and Qt6, +# a rebuild is needed both when JASP releases and when Arch bumps R or Qt6 — +# check-upstream.py decides which. + +on: + schedule: + - cron: '0 3 * * *' + workflow_dispatch: + inputs: + jasp_tag: + description: 'Specific JASP tag to build (blank = latest release)' + required: false + default: '' + force: + description: 'Build even if nothing changed' + type: boolean + default: false + push: + paths: + - '.gitea/workflows/build.yml' + - 'build/**' + - 'packaging/**' + - 'scripts/**' + +concurrency: + group: jasp-build + cancel-in-progress: false + +env: + GH_OWNER: ${{ vars.GH_OWNER }} + GH_REPO: ${{ vars.GH_REPO }} + AUR_MAINTAINER: ${{ vars.AUR_MAINTAINER }} + WORK_DIR: /srv/jasp-build + +jobs: + check: + runs-on: ubuntu-latest + outputs: + needs_build: ${{ steps.decide.outputs.needs_build }} + jasp_tag: ${{ steps.decide.outputs.jasp_tag }} + reasons: ${{ steps.decide.outputs.reasons }} + steps: + - uses: actions/checkout@v4 + + - name: Decide whether a rebuild is needed + id: decide + env: + JASP_TAG: ${{ inputs.jasp_tag }} + FORCE_BUILD: ${{ inputs.force }} + run: python3 scripts/check-upstream.py + + build: + needs: check + if: needs.check.outputs.needs_build == 'true' + runs-on: ubuntu-latest + # The R module tree is several hundred packages compiled from source; the + # full build runs for hours. The runner and Gitea job timeouts must allow + # for this too (see README). + timeout-minutes: 900 + steps: + - uses: actions/checkout@v4 + + - name: Report plan + run: | + echo "Building JASP ${{ needs.check.outputs.jasp_tag }}" + echo "Reasons: ${{ needs.check.outputs.reasons }}" + + - name: Prepare work directory + run: | + sudo rm -rf "${WORK_DIR}" + sudo mkdir -p "${WORK_DIR}/out" + sudo cp -a build packaging scripts "${WORK_DIR}/" + df -h / + + - name: Build JASP in an Arch container + run: | + docker rm -f jasp-build 2>/dev/null || true + docker run --rm --name jasp-build \ + -v "${WORK_DIR}:/work" \ + -e JASP_TAG="${{ needs.check.outputs.jasp_tag }}" \ + -e OUT_DIR=/work/out \ + -e JOBS=5 \ + archlinux:base-devel \ + bash /work/build/build-jasp.sh + + - name: Show build result + run: | + cat "${WORK_DIR}/out/build-info.json" + ls -lh "${WORK_DIR}/out/" + + - name: Generate PKGBUILD + run: | + python3 scripts/gen-pkgbuild.py \ + --build-info "${WORK_DIR}/out/build-info.json" \ + --template packaging/PKGBUILD.in \ + --out "${WORK_DIR}/out/PKGBUILD" \ + --gh-owner "${GH_OWNER}" \ + --gh-repo "${GH_REPO}" \ + --maintainer "${AUR_MAINTAINER}" + cat "${WORK_DIR}/out/PKGBUILD" + + - name: Publish tarball to GitHub Releases + env: + GH_TOKEN: ${{ secrets.GH_TOKEN }} + run: | + TAG="$(python3 -c 'import json;print(json.load(open("release-meta.json"))["tag"])')" + ASSET="$(python3 -c 'import json;print(json.load(open("release-meta.json"))["tarball"])')" + RELEASE_NOTES="Automated build of JASP ${{ needs.check.outputs.jasp_tag }} for Arch Linux. + Consumed by the AUR package jasp-desktop-bin. + Trigger: ${{ needs.check.outputs.reasons }}" \ + TAG="$TAG" ASSET="${WORK_DIR}/out/${ASSET}" \ + bash scripts/publish-github-release.sh + + - name: Push to AUR + env: + AUR_SSH_KEY: ${{ secrets.AUR_SSH_KEY }} + run: bash scripts/push-aur.sh "${WORK_DIR}/out/PKGBUILD" + + - name: Record built state + env: + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + run: bash scripts/record-state.sh + + - name: Clean up build tree + if: always() + run: | + sudo rm -rf "${WORK_DIR}/jasp-desktop" "${WORK_DIR}/build" "${WORK_DIR}/stage" || true + df -h / diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..031de7e --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +release-meta.json +__pycache__/ +*.pyc diff --git a/README.md b/README.md new file mode 100644 index 0000000..682daab --- /dev/null +++ b/README.md @@ -0,0 +1,109 @@ +# jasp-desktop-bin + +Automated binary builds of [JASP](https://jasp-stats.org/) for Arch Linux, published +to the AUR as `jasp-desktop-bin`. + +## Why this exists + +Upstream JASP ships installers for Windows and macOS only — there is **no official +Linux binary**. The official Linux channel is the Flathub build. The existing AUR +package `jasp-desktop` therefore builds everything from source, including +`buildAllDefaultJaspModules.R`, which compiles several hundred R packages. That is +the multi-hour build this repo exists to avoid: the compile happens once, here, and +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. + +A binary is therefore only valid for the R minor and Qt6 minor it was built +against. Two consequences: + +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. + +`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. + +## Layout + +| 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/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`. | +| `scripts/record-state.sh` | Commits `state.json` so the next nightly run knows what was built. | +| `packaging/PKGBUILD.in` | Template for the AUR package. | +| `state.json` | Last successfully built combination. | + +## Flow + +``` +nightly (03:00) ─► check-upstream.py + │ new JASP tag, or Arch bumped r / qt6-base? + ▼ + build-jasp.sh (Arch container, hours) + │ jasp-desktop--x86_64.tar.zst + build-info.json + ▼ + GitHub release ──► gen-pkgbuild.py ──► push to AUR + │ + ▼ + state.json committed back +``` + +## Required configuration + +Set these in the Gitea repo before the first run. + +**Variables** (Settings → Actions → Variables): + +| Name | Example | Meaning | +| --- | --- | --- | +| `GH_OWNER` | `asgerw` | GitHub account hosting the release repo | +| `GH_REPO` | `jasp-desktop-bin` | Release repo name | +| `AUR_MAINTAINER` | `Asger W ` | Written into the PKGBUILD header | + +**Secrets** (Settings → Actions → Secrets): + +| Name | Meaning | +| --- | --- | +| `GH_TOKEN` | GitHub PAT with `contents: write` on the release repo | +| `AUR_SSH_KEY` | Private SSH key registered with your AUR account | +| `GITEA_TOKEN` | Token with write access to this repo, for committing `state.json` | + +The GitHub repo `GH_OWNER/GH_REPO` must exist and be public — the AUR PKGBUILD +downloads the tarball from its releases, so it has to be reachable anonymously. + +## Runner requirements + +The build runs for hours and needs disk. On the Gitea runner host: + +- `runner.timeout` in `/var/lib/gitea-worker/config.yaml` must exceed the build + time (set to `16h`). +- Gitea's own job timeout (`[actions] DEFAULT_ACTIONS_TIMEOUT` in `app.ini`) 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. + +## Running it by hand + +``` +# Build a specific tag without waiting for the schedule +Actions → Build JASP binary and publish to AUR → Run workflow + jasp_tag = v0.98.1 force = true +``` + +## Caveat worth knowing + +AUR convention is that a `-bin` package repackages binaries published by upstream. +Here upstream publishes no Linux binary, so this one is built by us. That is +unusual, and a maintainer or TU may question it. It is not against the rules, but +the package description and this README should stay honest about where the binary +comes from. diff --git a/build/build-jasp.sh b/build/build-jasp.sh new file mode 100755 index 0000000..0bfd71d --- /dev/null +++ b/build/build-jasp.sh @@ -0,0 +1,185 @@ +#!/bin/bash +# 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. +# +# 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) +# +# 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. + +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)}" + +SRC_DIR=/build/jasp-desktop +BUILD_DIR=/build/build +STAGE_DIR=/build/stage +PREFIX=/opt/jasp-desktop + +mkdir -p "$OUT_DIR" /build + +log() { echo ":: $* "; } + +# ---------------------------------------------------------------- system deps +log "installing Arch dependencies" +pacman -Syu --noconfirm --needed \ + base-devel git cmake ninja gcc gcc-fortran make pkgconf patchelf \ + autoconf automake libtool bison flex gettext blas lapack ccache \ + 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 + +# 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. +sed -i "s|^#\?MAKEFLAGS=.*|MAKEFLAGS=\"-j${JOBS}\"|" /etc/makepkg.conf +export MAKEFLAGS="-j${JOBS}" + +# makepkg refuses to run as root. +if ! id builder &>/dev/null; then + useradd -m builder + echo "builder ALL=(ALL) NOPASSWD: ALL" >/etc/sudoers.d/builder +fi + +# jags and librdata are AUR-only; build them the same way a user would. +build_aur() { + local pkg="$1" + log "building AUR package $pkg" + sudo -u builder bash -euc " + cd /home/builder + rm -rf '$pkg' + git clone --depth 1 'https://aur.archlinux.org/$pkg.git' + cd '$pkg' + makepkg -si --noconfirm --needed + " +} +build_aur jags +build_aur librdata-git + +# JASP requires freexl >= 2.0.99; Arch ships 2.0.0, so use upstream's own fork +# exactly as the JASP CI does. +log "building jasp-stats/freexl" +rm -rf /build/freexl +git clone --depth 1 https://github.com/jasp-stats/freexl.git /build/freexl +cd /build/freexl +./configure --prefix=/usr +make -j"$JOBS" +make install + +# ------------------------------------------------------------------- checkout +log "cloning jasp-desktop $JASP_TAG" +rm -rf "$SRC_DIR" +git clone --depth 1 --branch "$JASP_TAG" --recurse-submodules --shallow-submodules \ + https://github.com/jasp-stats/jasp-desktop.git "$SRC_DIR" + +cd "$SRC_DIR" +JASP_COMMIT="$(git rev-parse HEAD)" + +# ---------------------------------------------------------------------- build +log "configuring" +export CMAKE_C_COMPILER_LAUNCHER=ccache +export CMAKE_CXX_COMPILER_LAUNCHER=ccache +export CMAKE_Fortran_COMPILER_LAUNCHER=ccache +export CFLAGS="${CFLAGS:-} -fPIC" +export CXXFLAGS="${CXXFLAGS:-} -fPIC" +export FCFLAGS="${FCFLAGS:-} -fPIC" +export FFLAGS="${FFLAGS:-} -fPIC" + +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 \ + -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. +log "building R modules — this is the slow part" +mkdir -p "$BUILD_DIR"/Modules/{binary_pkgs,manifests,module_libs,Tools} +cd "$SRC_DIR/Tools" +Rscript buildAllDefaultJaspModules.R + +# -------------------------------------------------------------------- staging +log "staging install tree" +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/" + +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/" +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" + +if [[ -d "$STAGE_DIR$PREFIX/share/icons/hicolor" ]]; then + install -d "$STAGE_DIR/usr/share/icons/hicolor" + cp -a "$STAGE_DIR$PREFIX/share/icons/hicolor/." "$STAGE_DIR/usr/share/icons/hicolor/" +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 + +# 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]+')" +BOOST_FULL="$(pkgver_of boost-libs)" + +JASP_VER="${JASP_TAG#v}" +TARBALL="jasp-desktop-${JASP_VER}-x86_64.tar.zst" + +log "creating $TARBALL" +cd "$STAGE_DIR" +tar --zstd -cf "$OUT_DIR/$TARBALL" . +SHA256="$(sha256sum "$OUT_DIR/$TARBALL" | awk '{print $1}')" +SIZE="$(stat -c%s "$OUT_DIR/$TARBALL")" + +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' + 'jags' 'librdata-git' +) + +# The tarball is a complete /opt + /usr install tree; there is nothing to +# compile or strip here. +options=('!strip' '!debug') + +source=("jasp-desktop-${pkgver}-${pkgrel}-x86_64.tar.zst::@DOWNLOAD_URL@") +sha256sums=('@SHA256@') + +package() { + cp -a "${srcdir}/opt" "${pkgdir}/opt" + cp -a "${srcdir}/usr" "${pkgdir}/usr" +} diff --git a/packaging/org.jaspstats.JASP.desktop b/packaging/org.jaspstats.JASP.desktop new file mode 100644 index 0000000..93e247c --- /dev/null +++ b/packaging/org.jaspstats.JASP.desktop @@ -0,0 +1,12 @@ +[Desktop Entry] +Name=JASP +Comment=Statistical analysis package +Keywords=statistics;data +Exec=/opt/jasp-desktop/bin/JASP %f +Terminal=false +Type=Application +MimeType=application/x-jaspstats-jasp;application/x-spss-sav; +StartupWMClass=org.jasp-stats.JASP +StartupNotify=true +Icon=org.jaspstats.JASP +Categories=Science;Education;DataVisualization diff --git a/scripts/check-upstream.py b/scripts/check-upstream.py new file mode 100755 index 0000000..07fafde --- /dev/null +++ b/scripts/check-upstream.py @@ -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()) diff --git a/scripts/gen-pkgbuild.py b/scripts/gen-pkgbuild.py new file mode 100755 index 0000000..8f53bda --- /dev/null +++ b/scripts/gen-pkgbuild.py @@ -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()) diff --git a/scripts/publish-github-release.sh b/scripts/publish-github-release.sh new file mode 100755 index 0000000..1a0eca7 --- /dev/null +++ b/scripts/publish-github-release.sh @@ -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}" diff --git a/scripts/push-aur.sh b/scripts/push-aur.sh new file mode 100755 index 0000000..1753ca5 --- /dev/null +++ b/scripts/push-aur.sh @@ -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" diff --git a/scripts/record-state.sh b/scripts/record-state.sh new file mode 100755 index 0000000..b88f848 --- /dev/null +++ b/scripts/record-state.sh @@ -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"