ci: add C# (NuGet) packaging for Substrait artifacts - #56
Draft
nielspardon wants to merge 2 commits into
Draft
Conversation
Adds a csharp/ tree alongside python/, java/, rust/ and cpp/, publishing three
independently versioned NuGet packages:
- Substrait.Protobuf — generated protobuf bindings (Google.Protobuf)
- Substrait.Antlr — generated ANTLR parsers (Antlr4.Runtime.Standard)
- Substrait.Extensions — extension YAMLs, text schemas and test cases as
embedded resources
All three multi-target netstandard2.0 and net8.0. Shared build settings and
NuGet metadata live in csharp/Directory.Build.props; each package has a sibling
*.Tests project asserting the same things the Python and Java artifacts assert.
Protobuf vendors the .proto files and generates C# at build time via Grpc.Tools,
so nothing generated is committed. ANTLR commits its parsers, as the Rust and
C++ targets do, because the ANTLR tool needs a JDK. Extensions ships data only,
matching Java and C++: NJsonSchema collapses the args oneOf
(enumeration_arg | value_arg | type_arg) to its first branch, which would
silently mis-deserialize most real extension files, so a typed layer is left to
downstream consumers.
Release machinery mirrors the other languages: csharp_{protobuf,antlr,
extensions}.yml do prechecks -> generate -> tag -> pack -> publish, gathered by
csharp_publish.yml and wired into publish_artifacts.yml. Publishing uses NuGet
trusted publishing (OIDC), the counterpart to the PyPI and crates.io flows.
Idempotency comes from scripts/csharp/nuget_package_exists.sh.
scripts/csharp/smoke_test.sh restores each packed .nupkg into a throwaway
consumer project, the counterpart to the Python wheel/sdist smoke tests.
ci_csharp.yml validates the machinery against the latest spec release on every
PR touching csharp/, and additionally packs and smoke tests the packages.
.NET 8 leaves support in November 2026. These packages are published on every spec release for years, so the modern leg should not be a framework that goes out of support months after the first release. Swapping it for net10.0 (LTS, supported to November 2028) excludes nobody: netstandard2.0 stays as the floor, so .NET 8 and .NET 9 consumers resolve that asset instead. The modern leg earns little either way — no source here uses an API netstandard2.0 lacks, Antlr4.Runtime.Standard publishes only net45 and netstandard2.0 assets, and a dependency's asset is chosen by the consumer's framework rather than ours — so it is kept mainly as headroom and to avoid advertising an out-of-support framework. smoke_test.sh now runs its statement from both a net8.0 and a net10.0 consumer project. net8.0 resolves the netstandard2.0 asset (net10.0 is not compatible with it) and net10.0 resolves net10.0, so between the two passes both published assets are executed rather than merely compiled. Verified by reading TargetFrameworkAttribute off the bound assembly: the net8.0 consumer reports .NETStandard,Version=v2.0 and the net10.0 consumer .NETCoreApp,Version=v10.0. This closes the runtime-coverage gap noted in the C# README, so no net472 test project is needed. Two implementation notes: - Each consumer framework gets its own project directory. The SDK's default source glob only skips `bin` and `obj`, so a shared directory with per-framework intermediate paths leaked the first pass's generated AssemblyInfo.cs into the second pass and failed with duplicate attributes. - Restore now uses a scratch NUGET_PACKAGES folder. NuGet keys its cache on id + version and will not re-extract a version it has already seen, so a rebuilt package whose version has not changed silently resolved the cached copy — meaning the smoke test could pass against stale content. The version is constant across runs here (the spec version, or 0.0.0-ci), which is precisely the case that goes wrong.
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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds a
csharp/tree alongsidepython/,java/,rust/andcpp/, publishing three independently versioned NuGet packages built from the spec release:Substrait.ProtobufGoogle.Protobuf)substrait-protobuf,substrait-prostSubstrait.AntlrAntlr4.Runtime.Standard)substrait-antlrSubstrait.Extensionsjava/extensions,cpp/substrait-extensionsAll three package ids are unclaimed on nuget.org. The protos already declare
option csharp_namespace = "Substrait.Protobuf", so the naming follows what the spec itself chose.Draft because two org-side settings are needed before a real release can run — see Before this can publish below.
What each package does, and why
All three multi-target
netstandard2.0andnet10.0.netstandard2.0does the real work — it keeps .NET Framework 4.6.2+, Mono and Unity consumers viable and is also what .NET 8/9 consumers resolve. The modern leg isnet10.0rather thannet8.0because .NET 8 leaves support in November 2026 and these packages are published on every spec release for years; nothing is excluded by that choice, since anything older falls back tonetstandard2.0. Shared build settings and NuGet metadata live incsharp/Directory.Build.props; each package has a sibling*.Testsproject.Substrait.Protobufvendors the.protofiles and generates C# at build time withGrpc.Tools, which suppliesprotocand the C# plugin as a build-only dependency. Nothing generated is committed, and noprotocinstall is needed. The C++ rationale for build-time generation (ABI coupling to a protobuf runtime) does not apply — generated C# is ordinary managed source — so this is tidiness rather than necessity. The.protofiles ship in the nupkg underproto/for consumers who want to run their own codegen.Substrait.Antlrcommits its generated parsers, as the Rust and C++ targets do: the ANTLR tool is a Java program, and neither consumers nor the publish workflow should need a JDK. Uses the stock ANTLR C# target, so no fork is required unlike Rust. Each grammar set gets its own namespace (Substrait.Antlr.SubstraitType,Substrait.Antlr.FuncTestCase), mirroring the Rust modules and C++ namespaces.Substrait.Extensionsships data only, matching Java and C++, with zero package dependencies.SubstraitExtensionslocates and reads the embedded files; the resource paths are identical to the Java artifact's (substrait/extensions/...).Why no generated type layer for extensions
I tried this first, since .NET does have a JSON Schema code generator and it would have matched Rust's
typifyand Python'sdatamodel-code-generator. It does not survive contact with the schemas.The Draft 2020-12
$defskeyword needs a rename to Draft-07definitionsfor NJsonSchema's reference resolver, which is a harmless keyword swap. The blocker isoneOf.simple_extensions_schema.yamlmodels a function argument asenumeration_arg | value_arg | type_argwith no discriminator property, and NJsonSchema collapses that to the first branch:Enumeration_arghasName,DescriptionandOptions— and noValue. Nearly every real extension file usesvalue_arg(- name: x, value: fp32), so the generated type would silently drop the field that matters. The output also leaks snake_case into public names (Type_variations,Scalar_functions,Type_param_defs) and producesAnonymous/Anonymous2/Impls3for inline schemas.Publishing that as package API would be worse than publishing none, and once shipped the names are a breaking change to fix. So the typed layer is out of scope here and left to downstream consumers. Worth a follow-up issue: it needs either a generator that handles undiscriminated unions or hand-written models, and that is arguably a decision for the binding repo rather than the packaging repo.
Release machinery
csharp_{protobuf,antlr,extensions}.ymlfollow the same prechecks → generate → tag → pack → publish shape asrust_prost.yml, gathered bycsharp_publish.ymland wired intopublish_artifacts.yml. Tags arecsharp/<PackageId>/vx.y.z, which satisfiestag_exists.sh's existing format check unchanged.Two differences from the other languages, both because of how NuGet works:
dotnet pack -p:Version=takes it at pack time, so there is no counterpart touv versionorscripts/rust/set_version.sh. NuGet SemVer has novprefix, so the tag'svis stripped.NuGet/login@v1exchanges the workflow's OIDC token for a short-lived API key, which is the direct counterpart to the PyPIid-token: writeflow andcrates-io-auth-action. No long-lived secret.New scripts:
scripts/csharp/nuget_package_exists.sh— idempotency, peer ofpypi_package_exists.shandcrate_exists.sh.scripts/csharp/smoke_test.sh— restores a packed.nupkginto a throwaway consumer project from a<clear />ed local feed and runs a statement against it. This is the counterpart to the Python wheel/sdist smoke tests, and it is what catches mistakes a project-reference build cannot see: wrong TFM asset folders, undeclared dependencies, or spec data left out of the nupkg.ci_csharp.ymlruns on PRs touchingcsharp/and additionally packs and smoke tests all three packages.Validation
Everything below was run locally against spec
v0.99.0using pixi's owndotnet-sdk(10.0.302, the same build conda-forge resolves for all four platforms inpixi.toml):pixi run csharp-generate-{protobuf,antlr,extensions}).pixi run csharp-build— all three libraries build bothnetstandard2.0andnet8.0; 15 tests pass (5 protobuf, 4 ANTLR, 6 extensions).dotnet packon the solution — 6 artifacts (.nupkg+.snupkgeach).smoke_test.shagainst every packed package, at both a release version and the0.0.0-ciprerelease CI uses.publish_artifacts.ymlnow fans out to 5 languages.The ANTLR tests assert the exact same parse trees as
python/substrait-antlr, so a grammar change that alters a tree shows up identically across targets.As with the other languages,
maincarries only the machinery — the generated parsers and vendored spec data are.gitkeepplaceholders here and land on release tags.Publishing prerequisites — done
Both org-side settings are now in place:
nugetGitHub environment, matching the existingpypiandcrates-ioenvironments.NUGET_USERrepository variable, plus trusted-publishing policies on nuget.org.One correction to an earlier version of this description, which said the policies are registered "for the three package ids": NuGet policies are not scoped to package ids. They are scoped to (Repository Owner, Repository, Workflow File, optional Environment), and apply to every package owned by the policy's package owner. The workflow filename is what matters, and since three separate workflows push, three policies are required:
substrait-iosubstrait-packagingcsharp_protobuf.ymlnugetsubstrait-iosubstrait-packagingcsharp_antlr.ymlnugetsubstrait-iosubstrait-packagingcsharp_extensions.ymlnugetThese name the reusable workflows rather than
publish_artifacts.yml, because the match is against the job's own workflow file. That is confirmed empirically rather than assumed: all three Python packages are on PyPI at 0.99.0, published through the identical nesting (python_protobuf.yml<-python_publish.yml<-publish_artifacts.yml).Policies are registered with package owner
substrait(the nuget.org organization), created by an individual account.NUGET_USERis set to that creator's username, not the organization's -- which is whatNuGet/loginrequires: it posts{"username": ...}to the token endpoint and its own failure message reads "Make sure you are using the username of the policy creator, not the policy owner".Notes for review
net10.0only, so they cover that asset.netstandard2.0gets its runtime coverage fromsmoke_test.sh, which consumes each packed package twice — from anet8.0project (which resolvesnetstandard2.0, sincenet10.0is not compatible) and from anet10.0project. Verified by readingTargetFrameworkAttributeoff the bound assembly:.NETStandard,Version=v2.0and.NETCoreApp,Version=v10.0respectively. Nonet472test project needed.0.0.0-ci).smoke_test.shnow restores into a scratchNUGET_PACKAGESfolder so the nupkg under test is always the one exercised. Caught while verifying asset selection — a rebuilt package resolved the previously cached copy.Antlr4.Runtime.Standardtrails the tool: 4.13.1 is the newest published C# runtime whilepixi.tomlpinsantlr >=4.13.2. Safe today, since ANTLR'sRuntimeMetaData.CheckVersioncompares major.minor only, but a bump to 4.14 needs a matching C# runtime release first. I left the shared pin alone (it is also Java's and C++'s) and noted the constraint inpixi.tomland the package README.Substrait.Protobuf.Tests/sits next toSubstrait.Protobuf/rather than inside it, unlike the Python and C++ layouts. Nesting a project inside another makes the library's default source globbing swallow the test sources; working around that needsDirectory.Build.targetsordering tricks, and sibling test projects are the .NET norm.Downstream
This is what lets substrait-csharp drop its
substraitsubmodule and its ownGrpc.Toolscodegen (see substrait-csharp#5, which is currently bumping that submodule by hand) in favour of aPackageReferencetoSubstrait.Protobuf.🤖 Generated with AI