diff --git a/.github/workflows/aot-tests.yml b/.github/workflows/aot-tests.yml
new file mode 100644
index 0000000..e2751f3
--- /dev/null
+++ b/.github/workflows/aot-tests.yml
@@ -0,0 +1,96 @@
+name: Native AOT tests
+
+# Publishes the test suite with PublishAot and runs the native binary on x64 and arm64.
+#
+# Not a duplicate of the JIT run: ILC resolves Vector256/Vector128.IsHardwareAccelerated at build
+# time against an instruction-set baseline instead of against the live CPU, so the same source can
+# compile to a different kernel. The x86-64-v3 legs guarantee AVX2; the default ones do not.
+
+on:
+ push:
+ branches: [ master, main ]
+ pull_request:
+ branches: [ master, main ]
+ workflow_dispatch:
+
+jobs:
+ aot:
+ name: ${{ matrix.rid }}${{ matrix.instruction-set && format(' ({0})', matrix.instruction-set) || '' }}
+ runs-on: ${{ matrix.runner }}
+ permissions:
+ contents: read
+
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - { runner: ubuntu-24.04, rid: linux-x64, instruction-set: '' }
+ - { runner: ubuntu-24.04, rid: linux-x64, instruction-set: x86-64-v3 }
+ - { runner: ubuntu-24.04-arm, rid: linux-arm64, instruction-set: '' }
+ - { runner: macos-26, rid: osx-arm64, instruction-set: '' }
+ - { runner: windows-2025, rid: win-x64, instruction-set: x86-64-v3 }
+
+ steps:
+ - uses: actions/checkout@v7
+
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v5
+ with:
+ dotnet-version: 10.0.x
+
+ # ILC links through clang and needs zlib's headers.
+ - name: Install native toolchain (Linux)
+ if: runner.os == 'Linux'
+ run: sudo apt-get update && sudo apt-get install -y clang zlib1g-dev
+
+ # An empty IlcInstructionSet is a no-op, so the default-baseline legs need no special case.
+ - name: Publish (Native AOT)
+ shell: bash
+ run: |
+ dotnet publish src/Base58Encoding.Tests/Base58Encoding.Tests.csproj \
+ --configuration Release --runtime ${{ matrix.rid }} --output aot-out \
+ -p:PublishAot=true -p:IlcInstructionSet=${{ matrix.instruction-set }} 2>&1 | tee publish.log
+
+ # Scoped to Base58Encoding so an unrelated xunit or SimpleBase warning cannot fail CI. Warning
+ # lines end with the driving project path, which contains the name too, so strip it first.
+ - name: Assert no AOT/trim warnings from the library
+ shell: bash
+ run: |
+ ours=$(grep -E 'warning IL[0-9]+' publish.log | sed -E 's/ \[[^][]*\]$//' | grep 'Base58Encoding' || true)
+ if [ -n "$ours" ]; then
+ echo "::error::Base58Encoding produced trim/AOT warnings:"
+ echo "$ours"
+ exit 1
+ fi
+
+ # No `dotnet` in front: a standalone binary. bash resolves the .exe suffix on Windows.
+ - name: Run the suite natively
+ shell: bash
+ run: |
+ chmod +x aot-out/Base58Encoding.Tests*
+ ./aot-out/Base58Encoding.Tests | tee native.log
+
+ # The xunit package is swapped on PublishAot, so the native legs discover tests with a source
+ # generator while every other run uses reflection. Source-generated discovery is a strict subset:
+ # a test it cannot see (generic test methods, interface-based attributes) is silently absent
+ # rather than an error, so without this both legs would go green while the native one tested
+ # less. Runtime-skipped tests still count toward Total -- VectorInstructionSetTests skips itself
+ # here -- so a mismatch means genuinely undiscovered tests. One leg is enough: discovery does not
+ # vary by RID or instruction set. The counts are read from the last summary line: a failing
+ # VectorInstructionSetTests dumps its child's console output, which carries one too.
+ - name: Discovery parity with the JIT package
+ if: matrix.rid == 'linux-x64' && matrix.instruction-set == ''
+ shell: bash
+ run: |
+ dotnet run --project src/Base58Encoding.Tests/Base58Encoding.Tests.csproj --configuration Release | tee jit.log
+ native=$(grep -oP 'Total: \K[0-9]+' native.log | tail -1)
+ jit=$(grep -oP 'Total: \K[0-9]+' jit.log | tail -1)
+ echo "native discovered $native, JIT discovered $jit"
+ if [ -z "$native" ] || [ -z "$jit" ]; then
+ echo "::error::could not parse a test count from one of the runs"
+ exit 1
+ fi
+ if [ "$native" != "$jit" ]; then
+ echo "::error::source-generated discovery found $native tests, reflection found $jit"
+ exit 1
+ fi
diff --git a/.github/workflows/publish-nuget.yml b/.github/workflows/publish-nuget.yml
index da9c186..ca94306 100644
--- a/.github/workflows/publish-nuget.yml
+++ b/.github/workflows/publish-nuget.yml
@@ -1,26 +1,12 @@
name: Build and Publish to NuGet
+# Release-only: tags and manual dispatch. Ordinary pushes and PRs are covered by tests.yml and
+# aot-tests.yml, which build the same solution across more platforms -- this one used to duplicate
+# that on every library change while never packing, since Pack is gated on a version being set.
on:
push:
tags:
- 'v*.*.*'
- branches: [ master, main ]
- paths:
- - 'src/Base58Encoding/**'
- - 'src/Directory.Build.props'
- - 'src/Directory.Packages.props'
- - 'src/NuGet.Config'
- - 'src/PACKAGE.md'
- - 'global.json'
- pull_request:
- branches: [ master, main ]
- paths:
- - 'src/Base58Encoding/**'
- - 'src/Directory.Build.props'
- - 'src/Directory.Packages.props'
- - 'src/NuGet.Config'
- - 'src/PACKAGE.md'
- - 'global.json'
workflow_dispatch:
inputs:
version:
@@ -59,7 +45,12 @@ jobs:
- name: Build
run: dotnet build src/Base58Encoding.slnx --configuration Release --no-restore
+ # The release path's own gate. tests.yml never runs on a tag -- a branches: filter does not
+ # match refs/tags/* -- so for a tag cut anywhere other than an already-tested master commit,
+ # nothing else has run the suite against this ref. Skipped only for a dispatch with no version,
+ # which builds without packing.
- name: Test
+ if: env.VERSION != ''
run: dotnet test --project src/Base58Encoding.Tests/Base58Encoding.Tests.csproj --configuration Release --no-build --verbosity normal
- name: Pack
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
new file mode 100644
index 0000000..5e90192
--- /dev/null
+++ b/.github/workflows/tests.yml
@@ -0,0 +1,57 @@
+name: Tests
+
+# The ordinary JIT test run, on every push and PR, with no path filter.
+#
+# aot-tests.yml covers the native legs and publish-nuget.yml runs the suite once more before it
+# packs, but that one is filtered to src/Base58Encoding/** and runs on arm64 only -- so without this
+# workflow a test-only change ran no JIT tests at all, and every x64 execution in CI came from an
+# ILC-compiled binary.
+#
+# The x64 legs are not redundant with the native ones. VectorInstructionSetTests drives its coverage
+# through DOTNET_EnableAVX2 and DOTNET_EnableHWIntrinsic, knobs ILC bakes past and an arm64 runner
+# ignores, so these are the only jobs where the Vector128 and scalar fallbacks actually execute.
+
+on:
+ push:
+ branches: [ master, main ]
+ pull_request:
+ branches: [ master, main ]
+ workflow_dispatch:
+
+jobs:
+ test:
+ name: ${{ matrix.rid }}
+ runs-on: ${{ matrix.runner }}
+ permissions:
+ contents: read
+
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - { runner: ubuntu-24.04, rid: linux-x64 }
+ - { runner: ubuntu-24.04-arm, rid: linux-arm64 }
+ - { runner: windows-2025, rid: win-x64 }
+ - { runner: macos-26, rid: osx-arm64 }
+
+ steps:
+ - uses: actions/checkout@v7
+
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v5
+ with:
+ dotnet-version: 10.0.x
+
+ # The whole solution, so a break in the benchmarks project surfaces here too: nothing else builds
+ # it, since publish-nuget.yml's path filter does not cover it either.
+ - name: Restore
+ run: dotnet restore src/Base58Encoding.slnx
+
+ - name: Build
+ run: dotnet build src/Base58Encoding.slnx --configuration Release --no-restore
+
+ # dotnet run rather than dotnet test: this is how the project drives xunit v3's own CLI, and it
+ # is what the child processes in VectorInstructionSetTests re-invoke. Those children add a few
+ # minutes -- they each re-run the whole suite with an instruction set disabled.
+ - name: Test
+ run: dotnet run --project src/Base58Encoding.Tests/Base58Encoding.Tests.csproj --configuration Release --no-build
diff --git a/.gitignore b/.gitignore
index 0c92fdb..26fdba4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -427,4 +427,6 @@ FodyWeavers.xsd
*.msm
*.msp
-nul
\ No newline at end of file
+nul
+# Native AOT publish output from a local aot-tests.yml dry run
+aot-out/
diff --git a/README.md b/README.md
index 9e3bcce..bc8f505 100644
--- a/README.md
+++ b/README.md
@@ -9,6 +9,13 @@ A .NET 10.0 Base58 encoding and decoding library with support for multiple alpha
- **Type Safe**: Leverages ReadOnlySpan and ReadOnlyMemory for safe memory operations
- **Intrinsics**: Uses SIMD `Vector256` and unrolled loop for counting leading zeros
- **Optimized Hot Paths**: Fast fixed-length encode/decode for 32-byte and 64-byte inputs using Firedancer-like optimizations
+- **Native AOT**: reflection-free and trim-clean; the suite itself runs as an AOT binary in CI
+
+## Native AOT
+
+The library is reflection-free and marked `IsAotCompatible`, so `PublishAot` and `PublishTrimmed` need no configuration and produce no warnings.
+
+Note that `Vector256` is not used under Native AOT: ILC resolves `Vector256.IsHardwareAccelerated` at build time against a baseline that excludes AVX2, so AOT builds run the `Vector128` kernel. Setting `x86-64-v3` restores it, at the cost of making AVX2 a hard startup requirement.
## Usage
diff --git a/src/Base58Encoding.Tests/Base58Encoding.Tests.csproj b/src/Base58Encoding.Tests/Base58Encoding.Tests.csproj
index 9ffc9f7..b0d7e3f 100644
--- a/src/Base58Encoding.Tests/Base58Encoding.Tests.csproj
+++ b/src/Base58Encoding.Tests/Base58Encoding.Tests.csproj
@@ -1,25 +1,39 @@
-
- Exe
- false
- Base58Encoding.Tests
-
-
-
-
-
- all
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
+
+ Exe
+ false
+ Base58Encoding.Tests
+
+
+
+
+ false
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ all
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Base58Encoding.Tests/SimpleBaseFuzzTests.cs b/src/Base58Encoding.Tests/SimpleBaseFuzzTests.cs
index 7fbc4e6..bd28503 100644
--- a/src/Base58Encoding.Tests/SimpleBaseFuzzTests.cs
+++ b/src/Base58Encoding.Tests/SimpleBaseFuzzTests.cs
@@ -12,8 +12,9 @@ namespace Base58Encoding.Tests;
//
// Ground truth is a BigInteger oracle (the literal definition of Base58), so the fuzz validates our
// code without trusting any third party. We also cross-check our encoder against SimpleBase's, but
-// not SimpleBase.Decode(ours): its 5.6.2 decoder drops the most-significant byte on some lengths
-// (ssg/SimpleBase#83, fixed in 5.6.3 — which we cannot take yet, see Directory.Packages.props).
+// not SimpleBase.Decode(ours): their decoder dropped the most-significant byte on some lengths
+// (ssg/SimpleBase#83), which ruled that direction out while we were pinned below the fix. We are
+// on 5.6.4 now and that bug is fixed, so the reverse cross-check could be enabled.
public class SimpleBaseFuzzTests
{
private const string Alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
diff --git a/src/Base58Encoding.Tests/VectorInstructionSetTests.cs b/src/Base58Encoding.Tests/VectorInstructionSetTests.cs
index d28c3ba..845f7ce 100644
--- a/src/Base58Encoding.Tests/VectorInstructionSetTests.cs
+++ b/src/Base58Encoding.Tests/VectorInstructionSetTests.cs
@@ -1,4 +1,5 @@
using System.Diagnostics;
+using System.Runtime.CompilerServices;
namespace Base58Encoding.Tests;
@@ -19,6 +20,9 @@ namespace Base58Encoding.Tests;
//
// Runs by default: these are the only tests covering the Vector128 and scalar paths. ChildMarker is
// what stops the child spawning its own child.
+//
+// Both knobs are JIT-only: ILC bakes the instruction sets in when it compiles, so a native run
+// skips these and the aot-tests workflow covers the kernels with per-baseline publishes instead.
public class VectorInstructionSetTests
{
private const string ChildMarker = "BASE58_VECTOR_CHILD";
@@ -36,6 +40,9 @@ public VectorInstructionSetTests(ITestOutputHelper output)
public void AllTests_Pass_WithVectorInstructionSetDisabled(string environmentVariable, string value)
{
Assert.SkipWhen(Environment.GetEnvironmentVariable(ChildMarker) == "1", "already the child run");
+ Assert.SkipUnless(
+ RuntimeFeature.IsDynamicCodeSupported,
+ "native AOT: instruction sets are baked in by ILC, so the env knob would do nothing");
#if DEBUG
const string configuration = "Debug";
diff --git a/src/Base58Encoding/Base58Encoding.csproj b/src/Base58Encoding/Base58Encoding.csproj
index a5997b0..d0e8b31 100644
--- a/src/Base58Encoding/Base58Encoding.csproj
+++ b/src/Base58Encoding/Base58Encoding.csproj
@@ -10,6 +10,7 @@
MIT
PACKAGE.md
false
+ true
diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props
index 1e9d92d..2204931 100644
--- a/src/Directory.Packages.props
+++ b/src/Directory.Packages.props
@@ -1,13 +1,13 @@
-
- true
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
+
+ true
+
+
+
+
+
+
+
+
+
+
diff --git a/src/PACKAGE.md b/src/PACKAGE.md
index eac5e5c..afe5c6f 100644
--- a/src/PACKAGE.md
+++ b/src/PACKAGE.md
@@ -55,6 +55,12 @@ Returns a typical upper bound for the decoded byte count from an encoded input o
Formula: `encodedLength * 733 / 1000 + 1`. Suitable for inputs without leading `1` characters.
For inputs that may contain leading `1`s, size the destination at `encodedLength` (safe upper bound).
+## Native AOT
+
+Reflection-free and marked `IsAotCompatible`, so `PublishAot` and `PublishTrimmed` need no configuration.
+
+Note that `Vector256` is not used under Native AOT: ILC fixes `Vector256.IsHardwareAccelerated` at build time against a baseline without AVX2, so AOT builds run the `Vector128` kernel.
+
## Usage
### Allocating API