From a5d858faad44f07c030dbf1801e36e21d0e1e13f Mon Sep 17 00:00:00 2001 From: Cervator Date: Tue, 11 Aug 2026 11:21:55 -0400 Subject: [PATCH 1/5] fix(ci): build Linux natives in a container to pin the glibc floor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CMakeLists.txt already links libgcc and libstdc++ statically, so glibc is the only dynamic dependency left in the published `.so` — which makes the build host's glibc the minimum supported Linux for every consumer, and Terasology ships these natives to players. #23 moved the Linux matrix from `ubuntu-20.04` to `ubuntu-24.04` to replace retired runner images, raising that floor from glibc 2.31 to 2.39 as a side effect. The result fails to load on anything older: `libm.so.6: version 'GLIBC_2.38' not found`, seen on Terasology's Debian-based build agents (MovingBlocks/Terasology#5359) and on any player distro below Ubuntu 24.04. Building inside `ubuntu:22.04` decouples the floor from whichever runner image GitHub currently offers, so the next retirement can't move it silently. There is no `ubuntu-20.04-arm` runner, so a container is also the only way to give the new `linux_aarch64` target the same floor as amd64. Co-Authored-By: Claude Opus 5 --- .github/workflows/allInOne.yml | 77 +++++++++++++++++++++++++++++++--- 1 file changed, 72 insertions(+), 5 deletions(-) diff --git a/.github/workflows/allInOne.yml b/.github/workflows/allInOne.yml index 2c4fa746e..1790a2e70 100644 --- a/.github/workflows/allInOne.yml +++ b/.github/workflows/allInOne.yml @@ -1,10 +1,31 @@ name: Build and Publish for all Platforms +# The Linux natives are compiled inside a container (`container: ubuntu:22.04` in the +# swig and build jobs below), not directly on the runner. That is deliberate. +# +# CMakeLists.txt already links libgcc and libstdc++ statically, so glibc is the only +# remaining dynamic dependency of the published .so -- which makes the build host's +# glibc the *minimum supported Linux* for everything that consumes JNBullet, and +# Terasology ships these natives to players. Building on the runner directly leaves +# that floor to drift upward silently every time GitHub retires a runner image. +# +# ubuntu:22.04 pins it at glibc 2.35. Anything at or above that -- Debian 12, +# Ubuntu 22.04, RHEL 9 -- can load the result. Raising this image drops distros for +# downstream users; do it on purpose, not as a side effect of a runner bump. +# +# It also has to stay at or above 22.04 while LLVM_MINGW_VERSION is pinned to an +# ubuntu-22.04 build, since that cross-compiler runs in the same job. + on: [push] env: SWIG_VERSION: 4.0.2 LLVM_MINGW_VERSION: 20260616 + # Part of every SWIG cache key. Bump it whenever the environment SWIG is built + # in changes: the cached binary is linked against that environment's glibc and + # pcre, so a stale entry restores a binary that cannot run. Bumped to 2 when the + # Linux jobs moved into a container. + SWIG_CACHE_VERSION: 2 jobs: validate-gradle-wrapper: @@ -16,15 +37,35 @@ jobs: swig: strategy: matrix: - os: [ubuntu-24.04, ubuntu-24.04-arm, macos-26-intel, macos-26] + include: + # Built inside the same image the natives are, so the cached binary runs + # in the container and on the plain runner the publish job uses. + - os: ubuntu-24.04 + container: ubuntu:22.04 + - os: ubuntu-24.04-arm + container: ubuntu:22.04 + - os: macos-26-intel + - os: macos-26 runs-on: ${{ matrix.os }} + container: ${{ matrix.container }} steps: + - name: Prepare container + if: matrix.container != '' + # The image is bare: no git for checkout, no sudo for the apt steps below, + # no toolchain. Runs before every other step for that reason. bison is + # needed because we build SWIG from a git tag archive rather than a release + # tarball, so its parser is not pre-generated; the hosted runners happen to + # ship it preinstalled and this image does not. + run: | + apt-get update + apt-get install -y --no-install-recommends \ + bison build-essential ca-certificates curl git sudo wget xz-utils - name: SWIG from cache id: cache-swig uses: actions/cache@v4 with: path: ${{ github.workspace }}/swig - key: ${{ runner.os }}-${{ runner.arch }}-swig-${{ env.SWIG_VERSION }} + key: ${{ runner.os }}-${{ runner.arch }}-swig-${{ env.SWIG_VERSION }}-v${{ env.SWIG_CACHE_VERSION }} - name: Install SWIG dependencies # Always install, even on a cache hit: this also provides the pcre runtime # library the cached swig binary is linked against, which isn't persisted @@ -53,10 +94,25 @@ jobs: build: strategy: matrix: - os: [ubuntu-24.04, ubuntu-24.04-arm, macos-26-intel, macos-26] + include: + # Linux natives build in the container, not on the runner -- see the + # glibc-floor note at the top of this file before changing the image. + - os: ubuntu-24.04 + container: ubuntu:22.04 + - os: ubuntu-24.04-arm + container: ubuntu:22.04 + - os: macos-26-intel + - os: macos-26 runs-on: ${{ matrix.os }} + container: ${{ matrix.container }} needs: [validate-gradle-wrapper, swig] steps: + - name: Prepare container + if: matrix.container != '' + run: | + apt-get update + apt-get install -y --no-install-recommends \ + build-essential ca-certificates cmake curl git sudo unzip wget xz-utils - uses: actions/checkout@v4 with: submodules: true @@ -64,7 +120,7 @@ jobs: uses: actions/cache@v4 with: path: ${{ github.workspace }}/swig - key: ${{ runner.os }}-${{ runner.arch }}-swig-${{ env.SWIG_VERSION }} + key: ${{ runner.os }}-${{ runner.arch }}-swig-${{ env.SWIG_VERSION }}-v${{ env.SWIG_CACHE_VERSION }} fail-on-cache-miss: true - name: Add SWIG to $PATH run: echo "${{ github.workspace }}/swig/bin" >> $GITHUB_PATH @@ -106,9 +162,20 @@ jobs: build/natives/*/*.dylib publish: runs-on: ubuntu-24.04 + # Same container as the Linux build jobs. Not for the ABI floor -- this job only + # repackages natives built elsewhere -- but because SWIG bakes its --prefix in at + # configure time. The cached binary looks for its runtime library under the + # workspace path it was configured with, and that path differs between a + # container job and a host job. + container: ubuntu:22.04 needs: [validate-gradle-wrapper, swig, build] if: github.ref == 'refs/heads/master' steps: + - name: Prepare container + run: | + apt-get update + apt-get install -y --no-install-recommends \ + build-essential ca-certificates curl git sudo unzip wget xz-utils - uses: actions/checkout@v4 with: submodules: true @@ -116,7 +183,7 @@ jobs: uses: actions/cache@v4 with: path: ${{ github.workspace }}/swig - key: ${{ runner.os }}-${{ runner.arch }}-swig-${{ env.SWIG_VERSION }} + key: ${{ runner.os }}-${{ runner.arch }}-swig-${{ env.SWIG_VERSION }}-v${{ env.SWIG_CACHE_VERSION }} fail-on-cache-miss: true - name: Add SWIG to $PATH run: echo "${{ github.workspace }}/swig/bin" >> $GITHUB_PATH From 63766985a2fa72dede7cda9b9d63cbe54b3d14a5 Mon Sep 17 00:00:00 2001 From: Cervator Date: Tue, 11 Aug 2026 12:03:29 -0400 Subject: [PATCH 2/5] fix(ci): pin run steps to bash for the container jobs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Container jobs default to `sh` rather than `bash`, since GitHub cannot assume bash exists in an arbitrary image. The OS-dispatch steps use `==`, which dash rejects with `[: Linux: unexpected operator` — and the `else` branch then exits 1 claiming the OS is unsupported, which reads as a platform problem rather than a shell one. Co-Authored-By: Claude Opus 5 --- .github/workflows/allInOne.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/allInOne.yml b/.github/workflows/allInOne.yml index 1790a2e70..9f2e63e3c 100644 --- a/.github/workflows/allInOne.yml +++ b/.github/workflows/allInOne.yml @@ -18,6 +18,13 @@ name: Build and Publish for all Platforms on: [push] +# Container jobs default to `sh`, not `bash` -- GitHub cannot assume bash exists in an +# arbitrary image. The steps below use `==` and other bashisms, so pin the shell rather +# than rewrite them to be POSIX-portable. +defaults: + run: + shell: bash + env: SWIG_VERSION: 4.0.2 LLVM_MINGW_VERSION: 20260616 From 4ac3787112dca4a51d34a051c83fae44b8ee636e Mon Sep 17 00:00:00 2001 From: Cervator Date: Tue, 11 Aug 2026 12:42:20 -0400 Subject: [PATCH 3/5] fix(ci): use $GITHUB_WORKSPACE when putting SWIG on PATH MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `${{ github.workspace }}` resolves to the host path even in a container job, so the PATH entry pointed at `/home/runner/work/...` while the container only has `/__w/...` — `swig: command not found` despite a reported cache hit. `actions/cache` maps the two itself, which is why its `path:` input works and this step did not. Co-Authored-By: Claude Opus 5 --- .github/workflows/allInOne.yml | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/.github/workflows/allInOne.yml b/.github/workflows/allInOne.yml index 9f2e63e3c..b4cd344b6 100644 --- a/.github/workflows/allInOne.yml +++ b/.github/workflows/allInOne.yml @@ -130,7 +130,11 @@ jobs: key: ${{ runner.os }}-${{ runner.arch }}-swig-${{ env.SWIG_VERSION }}-v${{ env.SWIG_CACHE_VERSION }} fail-on-cache-miss: true - name: Add SWIG to $PATH - run: echo "${{ github.workspace }}/swig/bin" >> $GITHUB_PATH + # $GITHUB_WORKSPACE, not ${{ github.workspace }}: the expression resolves to the + # host path, which does not exist inside a container. The env var is set per + # environment. actions/cache maps the two itself, so its `path:` input is fine + # either way -- a plain run step is not. + run: echo "$GITHUB_WORKSPACE/swig/bin" >> $GITHUB_PATH - name: Install libpcre3 if: runner.os == 'Linux' run: sudo apt-get install -y libpcre3-dev @@ -193,7 +197,11 @@ jobs: key: ${{ runner.os }}-${{ runner.arch }}-swig-${{ env.SWIG_VERSION }}-v${{ env.SWIG_CACHE_VERSION }} fail-on-cache-miss: true - name: Add SWIG to $PATH - run: echo "${{ github.workspace }}/swig/bin" >> $GITHUB_PATH + # $GITHUB_WORKSPACE, not ${{ github.workspace }}: the expression resolves to the + # host path, which does not exist inside a container. The env var is set per + # environment. actions/cache maps the two itself, so its `path:` input is fine + # either way -- a plain run step is not. + run: echo "$GITHUB_WORKSPACE/swig/bin" >> $GITHUB_PATH - name: Install libpcre3 run: sudo apt-get install -y libpcre3-dev - name: Check SWIG version From 3a0a26a1ee41570a8ad0886b559db9edacb2f733 Mon Sep 17 00:00:00 2001 From: Cervator Date: Tue, 11 Aug 2026 13:10:44 -0400 Subject: [PATCH 4/5] test(ci): assert the built Linux natives stay within the declared glibc floor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The container pins the floor; nothing yet proves the output honours it. This reads the symbol versions back off the built `.so` and fails the job if any exceeds the declared floor, so the next base-image change surfaces here rather than as an `UnsatisfiedLinkError` on a user's machine — which is how #23 was found. Co-Authored-By: Claude Opus 5 --- .github/workflows/allInOne.yml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/.github/workflows/allInOne.yml b/.github/workflows/allInOne.yml index b4cd344b6..fd315bc5e 100644 --- a/.github/workflows/allInOne.yml +++ b/.github/workflows/allInOne.yml @@ -163,6 +163,24 @@ jobs: uses: gradle/actions/setup-gradle@v3 - name: Build run: ./gradlew build buildNatives + - name: Check the glibc floor of the Linux natives + # Turns the container choice into something enforced rather than assumed. If a + # future change builds these on a newer base, this fails here instead of as an + # UnsatisfiedLinkError on a user's machine. Only glibc is checked because + # CMakeLists.txt links libgcc and libstdc++ statically. + if: matrix.container != '' + env: + GLIBC_FLOOR: '2.35' + run: | + required=$(objdump -T build/natives/linux_*_gcc/*.so | grep -o 'GLIBC_[0-9]\+\.[0-9]\+' | sed 's/GLIBC_//' | sort -uV) + echo "glibc symbol versions required by the built natives:" + echo "$required" | sed 's/^/ /' + highest=$(echo "$required" | tail -1) + if [ "$(printf '%s\n%s\n' "$GLIBC_FLOOR" "$highest" | sort -V | tail -1)" != "$GLIBC_FLOOR" ]; then + echo "::error::Natives require glibc $highest, above the declared floor of $GLIBC_FLOOR. See the note at the top of this workflow." + exit 1 + fi + echo "OK: highest requirement $highest is within the declared floor $GLIBC_FLOOR." - name: Upload artifacts uses: actions/upload-artifact@v4 with: From 8ed5abfe3c56c560ef7bffc703f4de678faa0846 Mon Sep 17 00:00:00 2001 From: Cervator Date: Tue, 11 Aug 2026 17:00:54 -0400 Subject: [PATCH 5/5] fix(ci): assert the measured glibc requirement, not the build image's MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round 1, CodeRabbit and Copilot both landing on the same conflation: the header comment used the container's glibc (2.35) as the compatibility claim, then listed RHEL 9 as covered — and RHEL 9 ships 2.34, so the claim contradicted itself. Copilot had the sharper framing: the consumer floor is the highest `GLIBC_*` symbol the linker references, which can be lower than the image's glibc, and here is 2.34. So `GLIBC_FLOOR` drops to that measured value. This makes the check strictly stronger — at 2.35 a change that started referencing 2.35 symbols would have passed while silently dropping RHEL 9. Also per Copilot: the check ran under `-e -o pipefail`, so a `grep` with no matches killed the step with no diagnostic. It now distinguishes "no natives", "objdump failed", and "no versions found" — the last being unverified rather than satisfied, which is the one that could have masked a regression. Co-Authored-By: Claude Opus 5 --- .github/workflows/allInOne.yml | 45 +++++++++++++++++++++++++--------- 1 file changed, 34 insertions(+), 11 deletions(-) diff --git a/.github/workflows/allInOne.yml b/.github/workflows/allInOne.yml index fd315bc5e..c673a49b2 100644 --- a/.github/workflows/allInOne.yml +++ b/.github/workflows/allInOne.yml @@ -9,12 +9,20 @@ name: Build and Publish for all Platforms # Terasology ships these natives to players. Building on the runner directly leaves # that floor to drift upward silently every time GitHub retires a runner image. # -# ubuntu:22.04 pins it at glibc 2.35. Anything at or above that -- Debian 12, -# Ubuntu 22.04, RHEL 9 -- can load the result. Raising this image drops distros for -# downstream users; do it on purpose, not as a side effect of a runner bump. +# Two different numbers are involved and it is worth keeping them apart. The build +# image supplies glibc 2.35, but that is only a ceiling -- what actually constrains +# consumers is the highest GLIBC_* symbol version the linker ends up referencing, +# which today is 2.34 (the release that folded libpthread and libdl into libc). +# GLIBC_FLOOR in the build job asserts that measured value, not the image's, so a +# change that started pulling in 2.35 symbols fails rather than quietly narrowing +# who can run the result. # -# It also has to stay at or above 22.04 while LLVM_MINGW_VERSION is pinned to an -# ubuntu-22.04 build, since that cross-compiler runs in the same job. +# At 2.34 the natives load on Debian 12, Ubuntu 22.04, and RHEL 9. Raising either +# number drops distros for downstream users; do it on purpose, not as a side effect +# of a runner bump. +# +# The image also has to stay at or above 22.04 while LLVM_MINGW_VERSION is pinned to +# an ubuntu-22.04 build, since that cross-compiler runs in the same job. on: [push] @@ -45,8 +53,8 @@ jobs: strategy: matrix: include: - # Built inside the same image the natives are, so the cached binary runs - # in the container and on the plain runner the publish job uses. + # Built inside the same image the natives are, so the cached binary runs in + # every Linux job that consumes it -- both build and publish are containers. - os: ubuntu-24.04 container: ubuntu:22.04 - os: ubuntu-24.04-arm @@ -170,12 +178,27 @@ jobs: # CMakeLists.txt links libgcc and libstdc++ statically. if: matrix.container != '' env: - GLIBC_FLOOR: '2.35' + # The measured requirement of the built natives, not the build image's glibc. + # See the note at the top of this file for why those differ. + GLIBC_FLOOR: '2.34' run: | - required=$(objdump -T build/natives/linux_*_gcc/*.so | grep -o 'GLIBC_[0-9]\+\.[0-9]\+' | sed 's/GLIBC_//' | sort -uV) + natives=$(ls build/natives/linux_*_gcc/*.so 2>/dev/null || true) + if [ -z "$natives" ]; then + echo "::error::No Linux natives found under build/natives/linux_*_gcc/ — nothing to check." + exit 1 + fi + if ! symbols=$(objdump -T $natives); then + echo "::error::objdump could not read the built natives." + exit 1 + fi + required=$(printf '%s\n' "$symbols" | grep -o 'GLIBC_[0-9]\+\.[0-9]\+' | sed 's/GLIBC_//' | sort -uV || true) + if [ -z "$required" ]; then + echo "::error::No GLIBC_* symbol versions found — the floor is unverified, which is not the same as satisfied." + exit 1 + fi echo "glibc symbol versions required by the built natives:" - echo "$required" | sed 's/^/ /' - highest=$(echo "$required" | tail -1) + printf '%s\n' "$required" | sed 's/^/ /' + highest=$(printf '%s\n' "$required" | tail -1) if [ "$(printf '%s\n%s\n' "$GLIBC_FLOOR" "$highest" | sort -V | tail -1)" != "$GLIBC_FLOOR" ]; then echo "::error::Natives require glibc $highest, above the declared floor of $GLIBC_FLOOR. See the note at the top of this workflow." exit 1