How this repository turns an upstream liblouis release into NuGet packages, and how the right native binary reaches a consumer's application at run time.
| Package | Contents |
|---|---|
LibLouis.NET |
The managed wrapper. Depends on runtime.liblouis. |
LibLouis.NET.Tables |
The braille tables from the upstream release, copied to the consumer's output directory. |
runtime.liblouis |
Metapackage. No payload; depends on all eight per-RID packages. |
runtime.<rid>.liblouis |
One native liblouis binary for a single runtime identifier. |
Supported runtime identifiers: linux-x86, linux-x64, linux-arm64, win-x86, win-x64,
win-arm64, osx-x64, osx-arm64.
The runtime packages are versioned after the upstream liblouis release; LibLouis.NET has its own
version. Both come from Directory.Build.props.
This is the part that is poorly documented upstream, so it is worth stating plainly. There are two different mechanisms and they are easy to confuse.
Each per-RID package contains exactly one payload file:
runtimes/win-x64/native/liblouis.dll
When a project references such a package, the .NET SDK records every one of these assets in the
application's .deps.json under runtimeTargets, tagged with its RID:
"runtime.win-x64.liblouis/3.39.0": {
"runtimeTargets": {
"runtimes/win-x64/native/liblouis.dll": { "rid": "win-x64", "assetType": "native" }
}
}At startup the host reads .deps.json, picks the entries whose RID is compatible with the machine
it is running on, and adds those directories to NATIVE_DLL_SEARCH_DIRECTORIES. That is the list
[LibraryImport("liblouis")] probes, so the correct binary is found without any code in the
wrapper. dotnet publish -r <rid> resolves the same graph at publish time and copies only the one
matching binary.
Because the metapackage's dependencies are unconditional, a consumer restores all eight per-RID
packages regardless of platform, and a RID-less dotnet build copies all eight into
bin/.../runtimes/. Only the matching one is ever loaded, and publishing with a RID narrows it to
one. This is the same approach SkiaSharp and SQLitePCLRaw take.
A package can also ship a root runtime.json declaring RID-conditional dependencies — "when
restoring for win-x64, also depend on runtime.win-x64.liblouis". That would let a consumer
download only the native package they actually need.
It was dropped because NuGet only evaluates runtime.json when a RID is in scope for the restore,
meaning RuntimeIdentifier/RuntimeIdentifiers is set or dotnet publish -r is used. In .NET 5
and later, portable RID-less builds are the default and UseRidGraph is off, so an ordinary
dotnet build / dotnet run would resolve no native package at all and fail at the first P/Invoke.
The mechanism dates from .NET Core 1.x/2.x, when every application had a RID.
To switch back, delete the dependency group from runtime.liblouis.nuspec and add a runtime.json
to the metapackage — nothing else in the repository depends on the choice.
Directory.Build.props is the single source of truth for the upstream version and its checksum;
the shell scripts read the values back out of it.
The solution is LibLouis.NET.slnx, the XML solution format, which needs a .NET SDK of 9.0.200 or
later. An older SDK does not report that usefully: SDK 8 fails with MSB4068: The element <Solution> is unrecognized, which does not obviously mean the SDK is too old.
There is deliberately no global.json declaring that floor. It would apply to every dotnet
invocation in the repository, including inside the build container, which runs an older SDK and
never touches the solution — it packs individual project files, which any SDK handles. Pinning the
floor globally to satisfy one script would force the container's image up for no reason.
| Command | Produces |
|---|---|
./build.sh |
Linux + Windows runtime packages and the metapackage, via the container. |
sh build/build_runtime_macos_packages.sh |
macOS runtime packages. Requires a Mac. |
sh build/build_metapackage.sh |
runtime.liblouis. Needs no native binaries. |
sh build/build_managed_packages.sh |
LibLouis.NET and LibLouis.NET.Tables. |
sh build/stage_tables.sh |
Copies upstream tables into LibLouis.NET.Tables. |
Everything lands in ./packages, or in $PACKAGE_OUTPUT_DIR if set.
Building the managed packages resolves runtime.liblouis, so the runtime packages have to be
available first. Point NUGET_LOCAL_FEED at a directory of freshly built packages to build against
those instead of what is already published:
NUGET_LOCAL_FEED=$PWD/packages sh build/build_managed_packages.shLinux and Windows binaries are cross-compiled in the container defined by Dockerfile. The
toolchain package names there only exist for amd64, so the images are pinned to linux/amd64 and
run under emulation on Apple Silicon.
Compiling and packing are separate stages, on images chosen for the job:
| Stage | Image | Installs |
|---|---|---|
gcc-build |
ubuntu:noble |
the cross toolchains |
llvm-build |
mstorsjo/llvm-mingw |
nothing |
pack |
dotnet/sdk |
nothing |
dotnet appears exactly once in the native build, to pack an already-compiled binary. Compiling C on
a dotnet/sdk image meant apt-getting a toolchain onto an image chosen for something else, and
pulling in packages the build never uses — which is how a 404 on linux-libc-dev, a dependency of
build-essential, once failed CI. The toolchain stages now leave binaries staged under
runtime.<rid>.liblouis/runtimes/, and build/pack_runtime_packages.sh packs whatever it finds
rather than a list that could fall out of step. Finding nothing is an error.
win-arm64 is the exception among the Windows targets: Ubuntu has no aarch64 mingw-w64 gcc, so it
uses llvm-mingw, taken from the image its own author
publishes and pinned to a dated release. That image already carries make, m4, curl and the
toolchain on PATH.
The Dockerfile is excluded from the build context. It is not needed inside any image, and
excluding it means editing the build definition does not invalidate every COPY . /source and
force a full recompile of every target.
llvm-mingw lives in its own container stage, and that separation is load bearing. It also ships
i686-w64-mingw32-gcc and x86_64-w64-mingw32-gcc, so merely having it on PATH alongside the
Ubuntu cross compilers silently takes over the win-x86 and win-x64 builds. That is not
hypothetical: it happened, and it broke win-x86, because clang treats the
-Wincompatible-pointer-types that gnulib trips on mingw as an error where gcc only warns.
Two upstream assumptions bite the ARM64 Windows build, both because liblouis takes *mingw* to
mean x86:
configure.acadds-Wl,--add-stdcall-aliasfor every mingw host. It decorates i386 stdcall symbols, means nothing on ARM64, and lld rejects unknown arguments where GNU ld ignored them.build_runtime_packages.shstrips the line fromconfigurefor this target only.- gnulib's
metadata.cassigns aDIR *to astruct gl_directory *. clang rejects it, so that one diagnostic is turned back into a warning throughCFLAGS.
Both were still present in liblouis 3.39.0, so expect them to survive an upstream bump.
The build runs make -C gnulib then make -C liblouis, not a top level make. The tree also
contains tools/, tables/, man/, doc/, tests/, python/ and windows/, none of which
reach a package.
The reason is not only speed. A failure under tools/ does not stop a top level make: the
win-arm64 build once emitted eight link errors there and still exited 0, which hid a real problem
in several thousand lines of log. Building only what is shipped means any failure is about the
thing being shipped. It also removes libtool's could not determine the host path warnings, which
come from the wrapper scripts it generates around uninstalled executables and were pure noise: 48
of them in a full build, none now.
gnulib has to be named explicitly and built first. liblouis links gnulib/libgnu.la, and make does
not build a sibling directory on demand — make -C liblouis alone stops with No rule to make target '../gnulib/libgnu.la'.
Skipping tables/ is safe because the two tables generated there with m4, nl-chardefs.uti and
nl-NL-g0.utb, are also shipped pre-generated in the release tarball, so LibLouis.NET.Tables
still stages a complete set.
The Windows targets are built with -static-libgcc. Without it, 32-bit mingw links against
libgcc_s_dw2-1.dll, because it uses DWARF-2 exception handling whose unwinder lives in that DLL.
That is a load time dependency, so liblouis.dll would fail to load on any machine without a mingw
installation, while working fine on a developer box that has one. 64-bit mingw uses SEH and never
had the problem, which is why it survived unnoticed: win-x64 is the target anyone would test.
Worth re-checking after a toolchain or upstream bump. The binaries should import nothing beyond
KERNEL32, msvcrt and the api-ms-win-crt-* system libraries:
llvm-readobj --coff-imports liblouis.dll | grep Name:Every native binary is inspected before it is packed, by build/verify_native_binary.sh, called
from pack_runtime_package. A binary that fails never becomes a package.
| Check | Why |
|---|---|
| Architecture matches the RID | A mis-targeted binary restores fine and never loads. |
| Every P/Invoke symbol is exported | Otherwise EntryPointNotFoundException at first use. |
| No dependency outside an allowlist | Catches the libgcc_s_dw2-1.dll class of bug. |
Max GLIBC_ version within the floor |
The floor decides which distributions can consume the packages, and it is a property of the build image. |
The expected symbols are read out of the EntryPoint attributes in LibLouis.NET/NativeMethod.cs
rather than listed in the script, so the check cannot drift away from what the wrapper actually
imports.
The glibc floor is MAX_GLIBC in the script, currently 2.34, which covers RHEL 9, Debian 12 and
Ubuntu 22.04 and later. linux-x86 sits exactly on it. Raising it drops support for older
distributions, so it should be a deliberate decision rather than a side effect of bumping the base
image.
Run it by hand against an extracted package to audit a published one:
sh build/verify_native_binary.sh win-x64 runtimes/win-x64/native/liblouis.dllIt needs tools that can read the format being checked. The container has them. On macOS,
brew install llvm covers all three formats, since llvm-readelf, llvm-nm and llvm-readobj read ELF,
PE and Mach-O alike; the script finds them under Homebrew's keg-only prefix. Without that, only the
macOS RIDs can be checked locally, because BSD nm has no -D and cannot read ELF.
SKIP_NATIVE_VERIFICATION=1 bypasses it, which is only reasonable when deliberately building
something the checks were not written for.
The Linux and macOS targets build with make -j. The Windows targets deliberately do not.
liblouis/Makefile makes all-am depend on liblouis-<soversion>.def, a file with no rule of its
own: it exists only as a side effect of linking liblouis.la with -Wl,--output-def. Under -j,
make is free to schedule the .def before that link and fails with No rule to make target. The
.def is only produced for Windows hosts, so ELF and Mach-O targets are unaffected. The failure is
a scheduling race, so it is intermittent rather than reliable, which is reason enough not to gamble
on it in CI.
Both macOS slices are built on whichever architecture the Mac happens to be, using clang -arch
plus an explicit --host. Passing --host also puts autoconf into cross-compilation mode so
configure does not try to execute test binaries for the foreign slice. The build verifies the
resulting architecture with file before packing.
.github/workflows/build.yml:
| Job | Runner | Does |
|---|---|---|
native-linux |
ubuntu-latest |
Container build: 6 runtime packages + metapackage. |
native-macos |
macos-latest |
The 2 macOS runtime packages. |
test |
ubuntu, windows, macOS | Restores from the built packages and runs the tests. |
managed |
ubuntu-latest |
LibLouis.NET and LibLouis.NET.Tables. |
publish |
ubuntu-latest |
Pushes to GitHub Packages. Tags matching v* only. |
managed is a separate job from native-linux because building LibLouis.NET resolves
runtime.liblouis, which depends on the macOS packages the container cannot produce. Jobs exchange
packages as workflow artifacts, and the consuming jobs assemble them into a local feed.
The test job is the end-to-end check on native selection. Restoring pulls all eight per-RID
packages onto each runner, and each runner then has to resolve the one matching binary before any
P/Invoke succeeds — so a mistake in the package layout or the metapackage dependencies fails here
rather than in a consumer's application.
Publishing is gated on a v* tag, so pushes and pull requests build and test without reaching the
feed. A tag does not map to a single package version, since the runtime and managed packages are
versioned independently; --skip-duplicate makes re-pushing an unchanged runtime package a no-op.