Add C and Fortran interfaces #5
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Test C and Fortran interfaces | |
| on: | |
| push: | |
| branches: | |
| - main | |
| paths: | |
| - 'interfaces/**' | |
| - 'src/**' | |
| - 'Project.toml' | |
| - '.github/workflows/test-libsmc.yml' | |
| pull_request: | |
| paths: | |
| - 'interfaces/**' | |
| - 'src/**' | |
| - 'Project.toml' | |
| - '.github/workflows/test-libsmc.yml' | |
| concurrency: | |
| group: ${{ github.workflow }}-${{ github.ref }} | |
| cancel-in-progress: ${{ startsWith(github.ref, 'refs/pull/') }} | |
| permissions: | |
| contents: read | |
| jobs: | |
| test: | |
| name: ${{ matrix.label }} | |
| runs-on: ${{ matrix.runner }} | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| include: | |
| - label: linux-x86_64 | |
| runner: ubuntu-latest | |
| juliac: juliac | |
| lib: libsmc.so | |
| libdir: lib | |
| - label: linux-aarch64 | |
| runner: ubuntu-24.04-arm | |
| juliac: juliac | |
| lib: libsmc.so | |
| libdir: lib | |
| - label: macos-arm64 | |
| runner: macos-latest | |
| juliac: juliac | |
| lib: libsmc.dylib | |
| libdir: lib | |
| - label: macos-x86_64 | |
| runner: macos-15-intel | |
| juliac: juliac | |
| lib: libsmc.dylib | |
| libdir: lib | |
| - label: windows-x86_64 | |
| runner: windows-latest | |
| juliac: juliac.bat | |
| lib: libsmc.dll | |
| libdir: bin | |
| steps: | |
| - uses: actions/checkout@v7 | |
| # ----------------------------------------------------------------------- | |
| # The C (smc.h) and Fortran (smc.f90) headers each expose the | |
| # SparseMatrixColorings.jl version as three integers (MAJOR/MINOR/PATCH). | |
| # Verify the committed headers match Project.toml so a version bump can | |
| # never ship with a stale header. Runs before any build step so it | |
| # validates exactly what is committed. | |
| # ----------------------------------------------------------------------- | |
| - name: Check header versions match Project.toml | |
| shell: bash | |
| run: | | |
| ver=$(grep -E '^version\s*=' Project.toml | head -1 | sed -E 's/.*"([^"]+)".*/\1/') | |
| IFS=. read -r major minor patch <<< "$ver" | |
| echo "Project.toml version: $ver (major=$major minor=$minor patch=$patch)" | |
| fail=0 | |
| check() { # check <file> <line-must-contain> | |
| grep -Fq "$2" "$1" || { echo "::error file=$1::expected '$2' (Project.toml is $ver)"; fail=1; } | |
| } | |
| check interfaces/include/smc.h "#define SMC_VERSION_MAJOR $major" | |
| check interfaces/include/smc.h "#define SMC_VERSION_MINOR $minor" | |
| check interfaces/include/smc.h "#define SMC_VERSION_PATCH $patch" | |
| check interfaces/include/smc.f90 "SMC_VERSION_MAJOR = $major" | |
| check interfaces/include/smc.f90 "SMC_VERSION_MINOR = $minor" | |
| check interfaces/include/smc.f90 "SMC_VERSION_PATCH = $patch" | |
| [ "$fail" -eq 0 ] && echo "Header versions match Project.toml ($ver)." | |
| exit $fail | |
| - uses: julia-actions/setup-julia@v3 | |
| with: | |
| version: '1.12' | |
| # ----------------------------------------------------------------------- | |
| # Install gfortran on macOS (not pre-installed on GitHub runners). | |
| # The Linux and Windows runners already ship a usable gfortran. | |
| # ----------------------------------------------------------------------- | |
| - name: Install gfortran (macOS) | |
| if: runner.os == 'macOS' | |
| uses: fortran-lang/setup-fortran@main | |
| with: | |
| compiler: 'gcc' | |
| version: '14' | |
| - name: Show gfortran version | |
| shell: bash | |
| run: gfortran --version | |
| # ----------------------------------------------------------------------- | |
| # Install JuliaC.jl (provides the juliac CLI). The revision is pinned so a | |
| # JuliaC release cannot silently change how the bundle is produced. | |
| # ----------------------------------------------------------------------- | |
| - name: Install JuliaC.jl | |
| shell: bash | |
| run: | | |
| julia --startup-file=no -e " | |
| import Pkg | |
| Pkg.Registry.add(\"General\") | |
| Pkg.Apps.add(url=\"https://github.com/JuliaLang/JuliaC.jl\", rev=\"v0.3.8\") | |
| " | |
| - name: Add juliac to PATH | |
| shell: bash | |
| run: | | |
| JULIAC_BIN=$(julia --startup-file=no -e "print(joinpath(DEPOT_PATH[1], \"bin\"))") | |
| echo "$JULIAC_BIN" >> "$GITHUB_PATH" | |
| # ----------------------------------------------------------------------- | |
| # Instantiate the SparseMatrixColorings.jl project. | |
| # ----------------------------------------------------------------------- | |
| - name: Instantiate Julia project | |
| shell: bash | |
| run: julia --startup-file=no --project=. -e "import Pkg; Pkg.instantiate()" | |
| # ----------------------------------------------------------------------- | |
| # Compile libsmc with --bundle so the library is fully self-contained | |
| # (Julia runtime bundled alongside) and dlopen works without any system | |
| # Julia in PATH on all platforms. | |
| # ----------------------------------------------------------------------- | |
| - name: Build libsmc | |
| shell: bash | |
| run: | | |
| OUTLIB="interfaces/build/${{ matrix.libdir }}/${{ matrix.lib }}" | |
| mkdir -p "$(dirname "$OUTLIB")" | |
| ${{ matrix.juliac }} \ | |
| --project . \ | |
| --compile-ccallable \ | |
| --trim=safe \ | |
| --bundle interfaces/build \ | |
| --output-lib "$OUTLIB" \ | |
| interfaces/src/LibSMC.jl | |
| # ----------------------------------------------------------------------- | |
| # Copy the SuiteSparse shared libraries into the bundle. | |
| # | |
| # SparseArrays pulls in SuiteSparse_jll, whose __init__ dlopens libamd & | |
| # friends. juliaC cannot trace a dlopen, so it leaves them out of the | |
| # bundle and the library aborts on the very first call. We copy them next | |
| # to the other bundled Julia shared libraries, which is already on | |
| # libsmc's runpath. | |
| # | |
| # The destination is discovered from libjulia-internal rather than | |
| # hardcoded, so this works for lib/julia (Unix) and bin (Windows) alike. | |
| # ----------------------------------------------------------------------- | |
| - name: Bundle SuiteSparse libraries | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| DEST=$(dirname "$(find interfaces/build -name 'libjulia-internal.*' | head -n 1)") | |
| [ -n "$DEST" ] || { echo "::error::could not locate the bundle's Julia library directory"; exit 1; } | |
| BINDIR=$(julia --startup-file=no -e 'print(Sys.BINDIR)') | |
| copied=0 | |
| for name in amd btf camd ccolamd cholmod colamd klu ldl rbio spqr suitesparseconfig umfpack; do | |
| for src in "$BINDIR"/../lib/julia "$BINDIR"/../lib "$BINDIR"; do | |
| [ -d "$src" ] || continue | |
| for f in "$src"/lib${name}.so* "$src"/lib${name}*.dylib "$src"/lib${name}*.dll; do | |
| [ -e "$f" ] || continue | |
| cp -a "$f" "$DEST/" && copied=$((copied + 1)) | |
| done | |
| done | |
| done | |
| echo "copied $copied SuiteSparse files into $DEST" | |
| [ "$copied" -gt 0 ] || { echo "::error::no SuiteSparse libraries found; the bundle would abort at runtime"; exit 1; } | |
| # ----------------------------------------------------------------------- | |
| # Regenerate the headers and fail if either differs from the committed | |
| # copy, so neither can drift from function_sigs / coloring_table.jl. One | |
| # generator run emits both smc.h and smc.f90, so both are checked. | |
| # --ignore-cr-at-eol keeps this honest on Windows, where the checkout may | |
| # have CRLF line endings while the generator writes LF. | |
| # ----------------------------------------------------------------------- | |
| - name: Generate smc.h / smc.f90 and check they are up to date | |
| shell: bash | |
| run: | | |
| julia --startup-file=no --project=. interfaces/scripts/generate_header.jl | |
| fail=0 | |
| for f in interfaces/include/smc.h interfaces/include/smc.f90; do | |
| if ! git ls-files --error-unmatch "$f" >/dev/null 2>&1; then | |
| # An untracked file is invisible to `git diff`, so the check below | |
| # would pass vacuously. Catch it explicitly. | |
| echo "::error file=$f::$(basename "$f") is generated but not tracked by git; commit it" | |
| fail=1 | |
| elif ! git diff --exit-code --ignore-cr-at-eol -- "$f"; then | |
| echo "::error file=$f::$(basename "$f") is out of date; regenerate it with 'julia --project=. interfaces/scripts/generate_header.jl' and commit the result" | |
| fail=1 | |
| fi | |
| done | |
| [ "$fail" -eq 0 ] && echo "interfaces/include/smc.h and interfaces/include/smc.f90 are up to date." | |
| exit $fail | |
| # ----------------------------------------------------------------------- | |
| # Verify the bundle is self-contained. | |
| # Linux uses a dynamic check: run an example with ONLY the bundle on the | |
| # library path (no system Julia), so a missing dlopen'd dependency makes it | |
| # fail. macOS/Windows can't isolate reliably via env vars (SIP strips | |
| # DYLD_*, Windows always searches system dirs), so they use a STATIC check | |
| # that inspects the shipped binaries directly — see the steps below. | |
| # ----------------------------------------------------------------------- | |
| - name: Verify bundle is self-contained (Linux) | |
| if: runner.os == 'Linux' | |
| shell: bash | |
| run: | | |
| LIBPATH="$(pwd)/interfaces/build/${{ matrix.libdir }}" | |
| gcc -O2 -o interfaces/build/selfcheck interfaces/examples/C/basic_coloring.c \ | |
| -I interfaces/include "$LIBPATH/${{ matrix.lib }}" -lm | |
| unset LD_LIBRARY_PATH | |
| export LD_LIBRARY_PATH="$LIBPATH:$LIBPATH/julia" | |
| interfaces/build/selfcheck | |
| # macOS: every dependency (otool -L) of the library and of each bundled | |
| # dylib must resolve to something the bundle actually ships or to a genuine | |
| # system lib. A loader-relative or bare dependency (@rpath/@loader_path/ | |
| # @executable_path/<name>, incl. a dylib's own install id) is fine only if a | |
| # dylib of that name is present in the bundle; a /usr/lib or /System path is | |
| # a system lib; any *other* absolute path (Homebrew, the runner's Julia | |
| # depot) is a hardcoded external dependency and a leak. We do not inspect | |
| # rpaths: a stray rpath is harmless unless a dependency needs it, which the | |
| # resolution check below already catches. | |
| - name: Verify bundle is self-contained (macOS) | |
| if: runner.os == 'macOS' | |
| shell: bash | |
| run: | | |
| set -uo pipefail | |
| BUNDLE="interfaces/build" | |
| find "$BUNDLE" -name '*.dylib' -exec basename {} \; | sort -u > bundled.txt | |
| : > leaks.txt | |
| { find "$BUNDLE" -name '*.dylib' | |
| echo "$BUNDLE/${{ matrix.libdir }}/${{ matrix.lib }}"; } | while IFS= read -r lib; do | |
| [ -f "$lib" ] || continue | |
| # Dependencies (skip line 1: the file header "path:"). | |
| otool -L "$lib" | tail -n +2 | awk '{print $1}' | while IFS= read -r dep; do | |
| case "$dep" in | |
| ""|/usr/lib/*|/System/*) continue ;; # system / empty | |
| /*) echo "LEAK abspath: $(basename "$lib") -> $dep" | tee -a leaks.txt; continue ;; | |
| esac | |
| # loader-relative or bare name: must be shipped in the bundle | |
| grep -Fqx "$(basename "$dep")" bundled.txt \ | |
| || echo "LEAK unbundled: $(basename "$lib") -> $dep" | tee -a leaks.txt | |
| done | |
| done | |
| if [ -s leaks.txt ]; then | |
| echo "::error::macOS bundle is NOT self-contained"; sort -u leaks.txt; exit 1 | |
| fi | |
| echo "macOS bundle is self-contained" | |
| # Windows: every import (objdump -p) of every bundled DLL must resolve to | |
| # another bundled DLL, a Windows API set (api-ms-win-*/ext-ms-*), or a real | |
| # system DLL in System32. Anything else would be picked up from the runner's | |
| # Julia install and is a leaked external dependency. | |
| - name: Verify bundle is self-contained (Windows) | |
| if: runner.os == 'Windows' | |
| shell: bash | |
| run: | | |
| set -uo pipefail | |
| BUNDLE="interfaces/build" | |
| SYS="/c/Windows/System32" | |
| : > leaks.txt | |
| find "$BUNDLE" -iname '*.dll' -printf '%f\n' | tr '[:upper:]' '[:lower:]' | sort -u > bundled.txt | |
| find "$BUNDLE" -iname '*.dll' | while IFS= read -r dll; do | |
| objdump -p "$dll" 2>/dev/null | grep 'DLL Name:' | sed 's/.*DLL Name: *//' | tr -d '\r' | while IFS= read -r dep; do | |
| depl="$(echo "$dep" | tr '[:upper:]' '[:lower:]')" | |
| case "$depl" in | |
| api-ms-win-*|ext-ms-*) continue ;; | |
| esac | |
| grep -qx "$depl" bundled.txt && continue | |
| { [ -f "$SYS/$dep" ] || [ -f "$SYS/$depl" ]; } && continue | |
| echo "LEAK: $(basename "$dll") -> $dep" | tee -a leaks.txt | |
| done | |
| done | |
| if [ -s leaks.txt ]; then | |
| echo "::error::Windows bundle is NOT self-contained"; sort -u leaks.txt; exit 1 | |
| fi | |
| echo "Windows bundle is self-contained" | |
| # ----------------------------------------------------------------------- | |
| # Run the Julia test suite (all supported combos x orders x precisions). | |
| # Loads LibSMC.jl as a plain Julia module — no dlopen of libsmc.so. | |
| # Loading a juliac-compiled lib from within Julia would trigger a second | |
| # runtime via ijl_adopt_thread and crash; the C tests cover the compiled | |
| # library from native processes. | |
| # ----------------------------------------------------------------------- | |
| - name: Run Julia tests | |
| shell: bash | |
| run: | | |
| julia --startup-file=no --project=. interfaces/test/test_libsmc.jl | |
| # ----------------------------------------------------------------------- | |
| # C tests and examples. Each program returns a non-zero exit status on | |
| # failure, so running it under `shell: bash` (-e) is the assertion. | |
| # ----------------------------------------------------------------------- | |
| # ---- C test: API behaviour (ABI layout, enums, options, error codes) ---- | |
| - name: Build C test (API behaviour) | |
| shell: bash | |
| run: | | |
| LIBPATH="$(pwd)/interfaces/build/${{ matrix.libdir }}" | |
| # -lm is implicit on Windows (CRT), harmless flag on Linux/macOS | |
| gcc -O2 -o interfaces/build/test_api_c \ | |
| interfaces/test/C/test_api.c \ | |
| -I interfaces/include \ | |
| "$LIBPATH/${{ matrix.lib }}" \ | |
| -lm | |
| - name: Run C test (API behaviour) | |
| shell: bash | |
| run: | | |
| LIBPATH="$(pwd)/interfaces/build/${{ matrix.libdir }}" | |
| export PATH="$LIBPATH:$PATH" | |
| export LD_LIBRARY_PATH="$LIBPATH:${LD_LIBRARY_PATH:-}" | |
| export DYLD_LIBRARY_PATH="$LIBPATH:${DYLD_LIBRARY_PATH:-}" | |
| interfaces/build/test_api_c | |
| # ---- C test: coloring (all combos, orders, groups, round-trips) ---- | |
| - name: Build C test (coloring) | |
| shell: bash | |
| run: | | |
| LIBPATH="$(pwd)/interfaces/build/${{ matrix.libdir }}" | |
| gcc -O2 -o interfaces/build/test_coloring_c \ | |
| interfaces/test/C/test_coloring.c \ | |
| -I interfaces/include \ | |
| "$LIBPATH/${{ matrix.lib }}" \ | |
| -lm | |
| - name: Run C test (coloring) | |
| shell: bash | |
| run: | | |
| LIBPATH="$(pwd)/interfaces/build/${{ matrix.libdir }}" | |
| export PATH="$LIBPATH:$PATH" | |
| export LD_LIBRARY_PATH="$LIBPATH:${LD_LIBRARY_PATH:-}" | |
| export DYLD_LIBRARY_PATH="$LIBPATH:${DYLD_LIBRARY_PATH:-}" | |
| interfaces/build/test_coloring_c | |
| # ---- C example: basic coloring ---- | |
| - name: Build C example (basic coloring) | |
| shell: bash | |
| run: | | |
| LIBPATH="$(pwd)/interfaces/build/${{ matrix.libdir }}" | |
| gcc -O2 -o interfaces/build/basic_coloring \ | |
| interfaces/examples/C/basic_coloring.c \ | |
| -I interfaces/include \ | |
| "$LIBPATH/${{ matrix.lib }}" \ | |
| -lm | |
| - name: Run C example (basic coloring) | |
| shell: bash | |
| run: | | |
| LIBPATH="$(pwd)/interfaces/build/${{ matrix.libdir }}" | |
| export PATH="$LIBPATH:$PATH" | |
| export LD_LIBRARY_PATH="$LIBPATH:${LD_LIBRARY_PATH:-}" | |
| export DYLD_LIBRARY_PATH="$LIBPATH:${DYLD_LIBRARY_PATH:-}" | |
| interfaces/build/basic_coloring | |
| # ---- C example: compress / decompress round-trip ---- | |
| - name: Build C example (compress/decompress) | |
| shell: bash | |
| run: | | |
| LIBPATH="$(pwd)/interfaces/build/${{ matrix.libdir }}" | |
| gcc -O2 -o interfaces/build/compress_decompress \ | |
| interfaces/examples/C/compress_decompress.c \ | |
| -I interfaces/include \ | |
| "$LIBPATH/${{ matrix.lib }}" \ | |
| -lm | |
| - name: Run C example (compress/decompress) | |
| shell: bash | |
| run: | | |
| LIBPATH="$(pwd)/interfaces/build/${{ matrix.libdir }}" | |
| export PATH="$LIBPATH:$PATH" | |
| export LD_LIBRARY_PATH="$LIBPATH:${LD_LIBRARY_PATH:-}" | |
| export DYLD_LIBRARY_PATH="$LIBPATH:${DYLD_LIBRARY_PATH:-}" | |
| interfaces/build/compress_decompress | |
| # ---- C example: symmetric coloring ---- | |
| - name: Build C example (symmetric coloring) | |
| shell: bash | |
| run: | | |
| LIBPATH="$(pwd)/interfaces/build/${{ matrix.libdir }}" | |
| gcc -O2 -o interfaces/build/symmetric_coloring \ | |
| interfaces/examples/C/symmetric_coloring.c \ | |
| -I interfaces/include \ | |
| "$LIBPATH/${{ matrix.lib }}" \ | |
| -lm | |
| - name: Run C example (symmetric coloring) | |
| shell: bash | |
| run: | | |
| LIBPATH="$(pwd)/interfaces/build/${{ matrix.libdir }}" | |
| export PATH="$LIBPATH:$PATH" | |
| export LD_LIBRARY_PATH="$LIBPATH:${LD_LIBRARY_PATH:-}" | |
| export DYLD_LIBRARY_PATH="$LIBPATH:${DYLD_LIBRARY_PATH:-}" | |
| interfaces/build/symmetric_coloring | |
| # ----------------------------------------------------------------------- | |
| # Fortran tests and examples. smc.f90 is an include file, not a module, | |
| # so there is nothing to compile ahead of time: -I interfaces/include is | |
| # all gfortran needs to resolve `include 'smc.f90'`. As with the C | |
| # programs, a non-zero exit status is the assertion. | |
| # ----------------------------------------------------------------------- | |
| # ---- Fortran test: mirrors the C tests through the Fortran binding ---- | |
| - name: Build Fortran test | |
| shell: bash | |
| run: | | |
| LIBPATH="$(pwd)/interfaces/build/${{ matrix.libdir }}" | |
| gfortran -O2 -o interfaces/build/test_smc_fortran \ | |
| interfaces/test/Fortran/test_smc.f90 \ | |
| -I interfaces/include \ | |
| "$LIBPATH/${{ matrix.lib }}" | |
| - name: Run Fortran test | |
| shell: bash | |
| run: | | |
| LIBPATH="$(pwd)/interfaces/build/${{ matrix.libdir }}" | |
| export PATH="$LIBPATH:$PATH" | |
| export LD_LIBRARY_PATH="$LIBPATH:${LD_LIBRARY_PATH:-}" | |
| export DYLD_LIBRARY_PATH="$LIBPATH:${DYLD_LIBRARY_PATH:-}" | |
| # gfortran block-buffers stdout when it is a pipe, so a program that | |
| # dies loses the tail of its log and the failure looks like it | |
| # happened earlier than it did -- which is exactly how the Windows | |
| # trim() crash first presented. Unbuffered, the last line printed is | |
| # the real one. Set SMC_TEST_VERBOSE=1 as well to echo every check. | |
| export GFORTRAN_UNBUFFERED_ALL=1 | |
| interfaces/build/test_smc_fortran | |
| # ---- Fortran examples: every .f90 in interfaces/examples/Fortran ---- | |
| # Globbed rather than listed one step per file so adding an example needs | |
| # no workflow edit; an empty directory is an error, not a silent pass. | |
| - name: Build Fortran examples | |
| shell: bash | |
| run: | | |
| LIBPATH="$(pwd)/interfaces/build/${{ matrix.libdir }}" | |
| shopt -s nullglob | |
| srcs=(interfaces/examples/Fortran/*.f90) | |
| [ "${#srcs[@]}" -gt 0 ] || { echo "::error::no Fortran examples found in interfaces/examples/Fortran"; exit 1; } | |
| for src in "${srcs[@]}"; do | |
| name=$(basename "$src" .f90) | |
| echo "building $src" | |
| gfortran -O2 -o "interfaces/build/${name}_fortran" \ | |
| "$src" \ | |
| -I interfaces/include \ | |
| "$LIBPATH/${{ matrix.lib }}" | |
| done | |
| - name: Run Fortran examples | |
| shell: bash | |
| run: | | |
| LIBPATH="$(pwd)/interfaces/build/${{ matrix.libdir }}" | |
| export PATH="$LIBPATH:$PATH" | |
| export LD_LIBRARY_PATH="$LIBPATH:${LD_LIBRARY_PATH:-}" | |
| export DYLD_LIBRARY_PATH="$LIBPATH:${DYLD_LIBRARY_PATH:-}" | |
| export GFORTRAN_UNBUFFERED_ALL=1 | |
| shopt -s nullglob | |
| srcs=(interfaces/examples/Fortran/*.f90) | |
| [ "${#srcs[@]}" -gt 0 ] || { echo "::error::no Fortran examples found in interfaces/examples/Fortran"; exit 1; } | |
| for src in "${srcs[@]}"; do | |
| name=$(basename "$src" .f90) | |
| echo "== running $name" | |
| "interfaces/build/${name}_fortran" | |
| done |