Skip to content

Commit 093d0b3

Browse files
committed
Add docker-manylinux_2_28-aarch64 branch
Builds ghcr.io/casadi/manylinux_2_28-aarch64-polyglot:production on GitHub-hosted ubuntu-24.04-arm runner. Base: quay.io/pypa/manylinux_2_28_aarch64:latest — PyPA upstream native aarch64 image. The jgillis/dockcross manylinux_2_28-aarch64:production variant is cross-from-x64 (manifest is actually amd64, exec-format-errors on ARM runners), so we deliberately use pypa upstream instead and install Rust + cbindgen ourselves on top via scripts/install-rust.sh. JULIA_CPU_TARGET=generic for aarch64-linux (JuliaCI's official target). DISABLE_LIBUNWIND=1 with the same JuliaLang/julia#61899 source patches as the x64 branch. Workflow + scripts mirror docker-manylinux_2_28-x64 (one branch per image keeps blast radius small). Signed-off-by: Joris Gillis <joris@yacoda.com>
0 parents  commit 093d0b3

5 files changed

Lines changed: 425 additions & 0 deletions

File tree

.github/workflows/build-image.yml

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
name: build manylinux_2_28-aarch64-polyglot
2+
3+
on:
4+
push:
5+
branches: [docker-manylinux_2_28-aarch64]
6+
paths:
7+
- Dockerfile
8+
- scripts/**
9+
- .github/workflows/build-image.yml
10+
workflow_dispatch:
11+
inputs:
12+
julia_version:
13+
description: Julia version to bake in
14+
required: false
15+
default: "1.12.6"
16+
push:
17+
description: Push the resulting image to ghcr.io (false = build-only)
18+
required: false
19+
default: "true"
20+
21+
permissions:
22+
contents: read
23+
packages: write
24+
25+
concurrency:
26+
group: build-${{ github.ref }}
27+
cancel-in-progress: true
28+
29+
env:
30+
IMAGE: ghcr.io/${{ github.repository_owner }}/manylinux_2_28-aarch64-polyglot
31+
# PyPA upstream — a NATIVE aarch64 image (runs on aarch64 hosts). The
32+
# jgillis/dockcross manylinux_2_28-aarch64 variant is cross-from-x64
33+
# (its manifest is actually amd64) and exec-format-errors on ARM runners.
34+
BASE_IMAGE: quay.io/pypa/manylinux_2_28_aarch64:latest
35+
TARGET_ARCH: aarch64
36+
BUILD_JOBS: 1
37+
38+
jobs:
39+
build:
40+
# GitHub-hosted ARM runner — needed for a native ARM Julia source build.
41+
# QEMU emulation of LLVM compile on an x86 runner is impractical (hours
42+
# and likely OOM). If `ubuntu-24.04-arm` isn't yet available to the
43+
# casadi org, swap for a self-hosted ARM runner.
44+
runs-on: ubuntu-24.04-arm
45+
steps:
46+
- uses: actions/checkout@v4
47+
- uses: docker/login-action@v3
48+
with:
49+
registry: ghcr.io
50+
username: ${{ github.actor }}
51+
password: ${{ secrets.GITHUB_TOKEN }}
52+
- uses: docker/setup-buildx-action@v3
53+
54+
- name: Resolve tags
55+
id: tags
56+
run: |
57+
JULIA_VERSION="${{ github.event.inputs.julia_version || '1.12.6' }}"
58+
SHA=$(echo "${{ github.sha }}" | cut -c1-7)
59+
{
60+
echo "julia_version=${JULIA_VERSION}"
61+
echo "tag_prod=${{ env.IMAGE }}:production"
62+
echo "tag_sha=${{ env.IMAGE }}:${SHA}"
63+
echo "tag_jl=${{ env.IMAGE }}:julia${JULIA_VERSION}"
64+
} >> "$GITHUB_OUTPUT"
65+
66+
- name: Build (and conditionally push) image
67+
uses: docker/build-push-action@v6
68+
with:
69+
context: .
70+
file: Dockerfile
71+
build-args: |
72+
BASE_IMAGE=${{ env.BASE_IMAGE }}
73+
TARGET_ARCH=${{ env.TARGET_ARCH }}
74+
JULIA_VERSION=${{ steps.tags.outputs.julia_version }}
75+
BUILD_JOBS=${{ env.BUILD_JOBS }}
76+
push: ${{ github.event.inputs.push != 'false' }}
77+
tags: |
78+
${{ steps.tags.outputs.tag_prod }}
79+
${{ steps.tags.outputs.tag_sha }}
80+
${{ steps.tags.outputs.tag_jl }}
81+
cache-from: type=gha,scope=manylinux_2_28-aarch64-polyglot
82+
cache-to: type=gha,scope=manylinux_2_28-aarch64-polyglot,mode=max
83+
provenance: false

Dockerfile

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
# syntax=docker/dockerfile:1.6
2+
#
3+
# casadi/polyglot — Linux native build (x86_64 or aarch64).
4+
#
5+
# Works on top of either:
6+
# - jgillis/dockcross-style image (manylinux_2_28 base with gcc-toolset,
7+
# Rust already at /opt/rustup, dockcross CC/CXX env vars set), or
8+
# - upstream pypa manylinux image (manylinux_2_28 base, no Rust yet —
9+
# scripts/install-rust.sh installs it).
10+
#
11+
# Bakes Julia from source with multi-target sysimage and DISABLE_LIBUNWIND.
12+
#
13+
# Build x64 (jgillis dockcross base, has Rust):
14+
# docker build -f Dockerfile.linux \
15+
# --build-arg BASE_IMAGE=ghcr.io/jgillis/manylinux_2_28-x64:production \
16+
# --build-arg TARGET_ARCH=x86_64 \
17+
# -t ghcr.io/casadi/polyglot:manylinux_2_28-x64 .
18+
#
19+
# Build aarch64 (pypa upstream — native aarch64 image, no Rust):
20+
# docker build -f Dockerfile.linux \
21+
# --build-arg BASE_IMAGE=quay.io/pypa/manylinux_2_28_aarch64:latest \
22+
# --build-arg TARGET_ARCH=aarch64 \
23+
# -t ghcr.io/casadi/polyglot:manylinux_2_28-aarch64 .
24+
25+
ARG BASE_IMAGE=ghcr.io/jgillis/manylinux_2_28-x64:production
26+
27+
# ───────────────────────── Stage 1: build Julia ──────────────────────────────
28+
29+
FROM ${BASE_IMAGE} AS julia-builder
30+
31+
ARG JULIA_VERSION=1.12.6
32+
ARG TARGET_ARCH=x86_64
33+
ARG BUILD_JOBS=4
34+
ENV JULIA_VERSION=${JULIA_VERSION} \
35+
TARGET_ARCH=${TARGET_ARCH} \
36+
BUILD_JOBS=${BUILD_JOBS}
37+
38+
# Tools Julia source build needs.
39+
# - flex absent from manylinux bases
40+
# - perl-core OpenSSL's Configure needs Data::Dumper + IPC::Cmd; bare
41+
# system Perl on pypa/CentOS-derived bases is minimal
42+
RUN ( dnf install -y flex perl-core 2>/dev/null && dnf clean all ) \
43+
|| ( yum install -y flex perl-core && yum clean all ) ; \
44+
rm -rf /var/cache/dnf /var/cache/yum 2>/dev/null || true
45+
46+
WORKDIR /build
47+
RUN curl -fsSL -o "julia-${JULIA_VERSION}-full.tar.gz" \
48+
"https://github.com/JuliaLang/julia/releases/download/v${JULIA_VERSION}/julia-${JULIA_VERSION}-full.tar.gz" \
49+
&& tar xzf "julia-${JULIA_VERSION}-full.tar.gz" \
50+
&& rm "julia-${JULIA_VERSION}-full.tar.gz"
51+
52+
COPY scripts/build-julia.sh /tmp/build-julia.sh
53+
RUN /tmp/build-julia.sh "/build/julia-${JULIA_VERSION}"
54+
55+
# ───────────────────────── Stage 2: final image ──────────────────────────────
56+
57+
FROM ${BASE_IMAGE}
58+
59+
ARG JULIA_VERSION=1.12.6
60+
ARG TARGET_ARCH=x86_64
61+
62+
# Install Rust if not present + cbindgen on top. Idempotent — when the base
63+
# already has Rust (jgillis dockcross), only cbindgen is installed.
64+
COPY scripts/install-rust.sh /tmp/install-rust.sh
65+
RUN /tmp/install-rust.sh && rm /tmp/install-rust.sh
66+
ENV CARGO_HOME=/opt/rustup/cargo \
67+
RUSTUP_HOME=/opt/rustup \
68+
PATH=/opt/rustup/cargo/bin:${PATH}
69+
70+
# Add the per-target Rust triple (so `cargo build --target ...` works without
71+
# a network fetch at run time).
72+
RUN case "${TARGET_ARCH}" in \
73+
x86_64) rustup target add x86_64-unknown-linux-gnu ;; \
74+
aarch64) rustup target add aarch64-unknown-linux-gnu ;; \
75+
*) echo "unsupported TARGET_ARCH=${TARGET_ARCH}" >&2; exit 1 ;; \
76+
esac
77+
78+
# Default cargo to build for the image's native triple.
79+
ENV TARGET_ARCH=${TARGET_ARCH} \
80+
CARGO_BUILD_TARGET=${TARGET_ARCH}-unknown-linux-gnu
81+
82+
# CARGO_TARGET_<TRIPLE>_LINKER + RUSTFLAGS=-C prefer-dynamic per jgillis/
83+
# dockcross 6a0fa8d. Linker path differs across bases (gcc-toolset-14 on
84+
# jgillis, plain /usr/bin/gcc on pypa), so resolve at build time.
85+
RUN UP=$(echo "${TARGET_ARCH}_unknown_linux_gnu" | tr '[:lower:]' '[:upper:]'); \
86+
GCC=$( command -v "${CC:-gcc}" 2>/dev/null || command -v gcc ); \
87+
printf 'export CARGO_TARGET_%s_LINKER=%s\nexport CARGO_TARGET_%s_RUSTFLAGS=-C prefer-dynamic\n' \
88+
"$UP" "$GCC" "$UP" > /etc/profile.d/polyglot-cargo.sh
89+
90+
# Julia install. The Julia source tree's `usr/` IS the install prefix.
91+
COPY --from=julia-builder /build/julia-${JULIA_VERSION}/usr /opt/julia
92+
ENV JULIA_HOME=/opt/julia \
93+
JULIA_PATH=/opt/julia \
94+
JULIA_VERSION=${JULIA_VERSION}
95+
ENV PATH=/opt/julia/bin:${PATH}
96+
97+
# Smoke test at image-build time so a broken build doesn't ship.
98+
RUN julia --version && \
99+
julia -e 'println("Julia ", VERSION, " — LLVM ", Base.libllvm_version, " — CPU ", Sys.CPU_NAME)' && \
100+
rustc --version && cargo --version && cbindgen --version && \
101+
test ! -e /opt/julia/lib/libunwind.so && \
102+
echo "polyglot image OK"
103+
104+
LABEL org.opencontainers.image.title="manylinux_2_28 polyglot (Julia + Rust + C/C++)" \
105+
org.opencontainers.image.description="Multi-language build environment with manylinux_2_28 ABI: gcc/clang toolset, Rust stable + cbindgen, Julia built from source with multi-target sysimage and no libunwind." \
106+
org.opencontainers.image.source="https://github.com/casadi/polyglot" \
107+
org.opencontainers.image.licenses="MIT" \
108+
casadi.polyglot.julia.version="${JULIA_VERSION}" \
109+
casadi.polyglot.base="${BASE_IMAGE}" \
110+
casadi.polyglot.arch="${TARGET_ARCH}"
111+
112+
WORKDIR /work

README.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# casadi/polyglot — docker-manylinux_2_28-aarch64 branch
2+
3+
Single-image branch that builds `ghcr.io/casadi/manylinux_2_28-aarch64-polyglot:production`.
4+
5+
Layered on `quay.io/pypa/manylinux_2_28_aarch64:latest` (PyPA upstream —
6+
a native aarch64 image; the jgillis dockcross variant is mislabeled amd64
7+
and would exec-format-error on ARM runners):
8+
9+
- GCC + cmake (from base, AlmaLinux 8 aarch64)
10+
- Rust stable + cargo + cbindgen 0.28 (installed here via
11+
`scripts/install-rust.sh` — pypa base lacks Rust by default)
12+
- Cargo cross-link env (`CARGO_BUILD_TARGET=aarch64-unknown-linux-gnu`)
13+
- Julia 1.12.x built from source with `JULIA_CPU_TARGET=generic`
14+
(JuliaCI's official aarch64-linux target) and `DISABLE_LIBUNWIND=1`
15+
(incl. the JuliaLang/julia#61899 source patches).
16+
17+
CI runs on `ubuntu-24.04-arm` (GitHub-hosted ARM runner) for a native
18+
build. Do NOT use QEMU emulation — Julia's LLVM source build is
19+
impractical under emulation. If `ubuntu-24.04-arm` isn't available to
20+
the casadi org, swap the runner for a self-hosted ARM machine.
21+
22+
Build is ~2-3 h wall (LLVM dominates; aarch64 runners tend to be slower
23+
than x64 ubuntu-latest).
24+
25+
Sister branches:
26+
- `docker-manylinux_2_28-x64` — native Linux x64
27+
- `docker-windows-shared-x64-posix` — Windows cross-build
28+
- `main` — libMad consumer workflow

scripts/build-julia.sh

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
#!/bin/bash
2+
# Build Julia from source with the polyglot image's settings:
3+
# - USE_BINARYBUILDER=0 → fully self-contained source build
4+
# - multi-target sysimage → official JuliaCI x86_64 / aarch64 target list
5+
# - DISABLE_LIBUNWIND=1 → no libunwind in the bundle (Matlab clash workaround)
6+
# requires the JuliaLang/julia#61899 fix patched
7+
# into src/{signals-unix.c,stackwalk.c}
8+
#
9+
# Args: $1 = path to extracted Julia source tree (containing Make.inc)
10+
# Env: TARGET_ARCH (x86_64 | aarch64) — defaults to native
11+
# BUILD_JOBS — make parallelism (default 4)
12+
set -euo pipefail
13+
SRC="${1:?Julia source tree path required}"
14+
TARGET_ARCH="${TARGET_ARCH:-$(uname -m)}"
15+
BUILD_JOBS="${BUILD_JOBS:-4}"
16+
17+
cd "$SRC"
18+
19+
# JULIA_CPU_TARGET — multi-target string matching JuliaCI's official binary
20+
# builds (utilities/build_envs.sh).
21+
case "$TARGET_ARCH" in
22+
x86_64)
23+
JULIA_CPU_TARGET="generic;sandybridge,-xsaveopt,clone_all;haswell,-rdrnd,base(1);x86-64-v4,-rdrnd,base(1)"
24+
;;
25+
aarch64)
26+
# JuliaCI ships only "generic" for aarch64-linux.
27+
JULIA_CPU_TARGET="generic"
28+
;;
29+
*)
30+
echo "Unsupported TARGET_ARCH=$TARGET_ARCH" >&2; exit 1
31+
;;
32+
esac
33+
34+
cat > Make.user <<EOF
35+
USE_BINARYBUILDER=0
36+
JULIA_CPU_TARGET=${JULIA_CPU_TARGET}
37+
DISABLE_LIBUNWIND:=1
38+
EOF
39+
# HOSTCC must be a single token. Make.inc line 445 unconditionally does
40+
# `HOSTCC = \$(CC)` which then gets `-m\$(BINARY)` appended → HOSTCC becomes
41+
# the two-word "gcc -m64". libwhich.mk's recipe `\$(MAKE) -C scratch/...
42+
# CC="\$(HOSTCC)" libwhich` then expands to `make ... CC="gcc -m64" libwhich`.
43+
# Under BuildKit's docker build with -j>1, the sub-make's MAKEFLAGS-driven
44+
# re-tokenization splits this and a stray ARCH token ("x86_64" or "aarch64")
45+
# leaks in as a positional arg to gcc — `gcc: error: x86_64: linker input
46+
# file not found`. Setting HOSTCC in Make.user does NOT work because line
47+
# 445 overwrites it. The fix is to pass HOSTCC/HOSTCXX on the make command
48+
# line so they win over makefile assignments.
49+
MAKE_VARS=(HOSTCC=gcc HOSTCXX=g++)
50+
51+
echo "=== Make.user ==="
52+
cat Make.user
53+
echo
54+
echo "=== Toolchain ==="
55+
set +o pipefail # SIGPIPE on `head -1` would otherwise abort under `set -e`
56+
gcc --version 2>/dev/null | head -1 || true
57+
g++ --version 2>/dev/null | head -1 || true
58+
gfortran --version 2>/dev/null | head -1 || true
59+
cmake --version 2>/dev/null | head -1 || true
60+
make --version 2>/dev/null | head -1 || true
61+
echo -n "glibc: "; ldd --version 2>&1 | head -1 || true
62+
set -o pipefail
63+
echo
64+
65+
echo "=== Env vars Julia's Make.inc reads ==="
66+
for v in ARCH CC CXX FC HOSTCC HOSTCXX MARCH MCPU MTUNE CFLAGS CXXFLAGS \
67+
CPPFLAGS LDFLAGS XC_HOST CROSS_TRIPLE CROSS_ROOT MAKEFLAGS MFLAGS; do
68+
eval "val=\${$v-<unset>}"
69+
printf ' %-15s = %s\n' "$v" "$val"
70+
done
71+
echo
72+
echo "=== Make's view (single-shot, no build) ==="
73+
# Print what Julia's outer make computes for the suspect vars without
74+
# actually building anything. Helps diagnose the libwhich x86_64 issue.
75+
make --no-print-directory print-CC print-HOSTCC print-CFLAGS \
76+
print-MARCH print-MCPU print-MTUNE 2>&1 \
77+
| head -20 || true
78+
echo
79+
80+
# Apply DISABLE_LIBUNWIND fix patches in-place via python string replacement.
81+
# This is more robust than `patch` against minor line-number drift across
82+
# Julia versions (the same fix submitted as JuliaLang/julia#61899). When the
83+
# upstream Julia version baked in here already has the fix, the script no-ops.
84+
echo "=== Apply DISABLE_LIBUNWIND fix patches ==="
85+
python3 <<'PY'
86+
import re, sys
87+
88+
# Patch 1 — signals-unix.c: move signal_bt_data/_size declarations out of
89+
# the libunwind guard so they always exist. signal_listener uses them
90+
# unconditionally; with libunwind disabled they stay at zero.
91+
p = 'src/signals-unix.c'
92+
s = open(p).read()
93+
needle = ('#if !defined(JL_DISABLE_LIBUNWIND)\n\n'
94+
'static jl_bt_element_t signal_bt_data[JL_MAX_BT_SIZE + 1];\n'
95+
'static size_t signal_bt_size = 0;\n')
96+
replacement = (
97+
'// polyglot patch (also JuliaLang/julia#61899): declare these\n'
98+
'// unconditionally — signal_listener uses them outside any libunwind guard.\n'
99+
'static jl_bt_element_t signal_bt_data[JL_MAX_BT_SIZE + 1];\n'
100+
'static size_t signal_bt_size = 0;\n\n'
101+
'#if !defined(JL_DISABLE_LIBUNWIND)\n\n'
102+
)
103+
if needle in s:
104+
open(p, 'w').write(s.replace(needle, replacement, 1))
105+
print(f'patched {p}')
106+
elif 'polyglot patch (also JuliaLang/julia#61899)' in s or \
107+
'declare these\n// unconditionally' in s:
108+
print(f'{p} already patched, skipping')
109+
else:
110+
sys.exit(f'PATCH1 FAILED: needle not found in {p}')
111+
112+
# Patch 2 — stackwalk.c: short-circuit jl_simulate_longjmp under
113+
# JL_DISABLE_LIBUNWIND (bt_context_t becomes int, the function body's
114+
# uc_mcontext etc. don't compile).
115+
p = 'src/stackwalk.c'
116+
s = open(p).read()
117+
needle = ('int jl_simulate_longjmp(jl_jmp_buf mctx, bt_context_t *c) JL_NOTSAFEPOINT\n'
118+
'{\n'
119+
'#if (defined(_COMPILER_ASAN_ENABLED_) || defined(_COMPILER_TSAN_ENABLED_))\n')
120+
replacement = (
121+
'int jl_simulate_longjmp(jl_jmp_buf mctx, bt_context_t *c) JL_NOTSAFEPOINT\n'
122+
'{\n'
123+
'#if defined(JL_DISABLE_LIBUNWIND)\n'
124+
' (void)mctx; (void)c;\n'
125+
' return 0;\n'
126+
'#elif (defined(_COMPILER_ASAN_ENABLED_) || defined(_COMPILER_TSAN_ENABLED_))\n'
127+
)
128+
if needle in s:
129+
open(p, 'w').write(s.replace(needle, replacement, 1))
130+
print(f'patched {p}')
131+
elif '#if defined(JL_DISABLE_LIBUNWIND)\n (void)mctx; (void)c;' in s:
132+
print(f'{p} already patched, skipping')
133+
else:
134+
sys.exit(f'PATCH2 FAILED: needle not found in {p}')
135+
PY
136+
echo
137+
138+
echo "=== Build ==="
139+
date
140+
time make -j"$BUILD_JOBS" "${MAKE_VARS[@]}"
141+
date
142+
echo
143+
144+
echo "=== Smoke test ==="
145+
./julia -e 'println("Julia ", VERSION, " — LLVM ", Base.libllvm_version); println("CPU ", Sys.CPU_NAME)'
146+
147+
echo "=== libunwind check (must be empty) ==="
148+
if compgen -G "usr/lib/libunwind*" > /dev/null; then
149+
echo "ERROR: libunwind made it into usr/lib even with DISABLE_LIBUNWIND=1" >&2
150+
ls usr/lib/libunwind* >&2
151+
exit 1
152+
fi
153+
echo "(no libunwind in usr/lib — good)"
154+
155+
echo "=== Done ==="

0 commit comments

Comments
 (0)