Skip to content

Add C and Fortran interfaces #5

Add C and Fortran interfaces

Add C and Fortran interfaces #5

Workflow file for this run

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