feat(csharp): generate the transport options class from the Rust schema - #745
Draft
wkirschenmann wants to merge 8 commits into
Draft
wkirschenmann wants to merge 8 commits into
wkirschenmann wants to merge 8 commits into
Conversation
☂️ Python Coverage
Overall Coverage
New FilesNo new covered files... Modified FilesNo covered modified files...
|
wkirschenmann
force-pushed
the
wk/feat/csharp-options-generator
branch
2 times, most recently
from
August 9, 2026 10:40
f230436 to
ef0fd75
Compare
The armonik-transport crate describes its flat option vocabulary as a JSON schema. A
console tool reads that schema and writes TransportOptions, the class a C# caller
fills in: every option a string defaulting to "", documented from the schema's own
descriptions, and ToTransportJson writing the document the transport reads. The schema
is an argument, so the tool is not tied to where that file eventually lives.
The class is the union of the options the schema names, those at its root and those in
every allOf/anyOf branch, deduplicated by name with the first description winning. A
name really does come back with a different description, and the first describes the
option rather than a shape.
No validation is generated. The anyOf groups are tautological, one branch requiring
nothing, so every document satisfies the group; they document the accepted shapes
rather than constrain them, and the transport stays the single validator, refusing by
option name. ToTransportJson leaves an empty option out, since the schema requires
none of them and a missing key reads as the empty string, and it writes its own JSON
so that hosting the generated file costs no serialisation dependency.
The output is one byte sequence on any platform: newlines, no byte order mark, and a
licence year that is a literal rather than the clock, so that regenerating and diffing
is a check. The file opens with an auto-generated banner, so that a reformatter leaves
those bytes alone.
An option the tool cannot spell stops it rather than reaching the compiler: a value
typed as anything but a string is a change of contract, and a name that is not
PascalCase is not the vocabulary's and may be a C# keyword.
Verification, from the worktree root, on a tree cleaned of bin and obj:
dotnet build packages/csharp/tools/ArmoniK.Api.TransportOptionsGenerator
0 Warning(s), 0 Error(s)
cd packages/rust && cargo run -q -p armonik-transport --features schema \
--example generate_schema > $TMP/tree.schema.json
dotnet run --project packages/csharp/tools/ArmoniK.Api.TransportOptionsGenerator -- \
--schema $TMP/tree.schema.json --output $TMP/gen1.cs
385 lines, 28 options; a second run into gen2.cs compares equal
--help exit 0, the usage
--schema x exit 1, both arguments named
--schema <a file that is not a schema> ... exit 2
--schema <the schema> --output no/such/dir/x exit 3
The generator has nothing to check it against until the schema it reads is committed
somewhere. A snapshot of that schema and the class written from it live next to the
tests, so regenerating and comparing is a check that runs today: the same schema has
to give the same bytes, and the bytes are in the tree to compare with.
The snapshot is not the file the FFI headers will carry. The gate that regenerates the
class from that file, and the CI job that runs it, come with the PR that commits it.
The golden TransportOptions.cs is compiled into the test project as well as read by
it, which is what proves it is valid C#, in the namespace asked for, with every escape
closed. The two fixtures keep their newlines through checkout, since a comparison of
bytes is one a platform must not decide.
Twenty-eight options, and one name that the schema describes twice: ProxyAddress
documents a credential-free URL in one proxy branch and a URL carrying its credentials
in the other. The first wins, and a test says so, because that is the rule a reader
would otherwise have to infer from the golden file.
Verification, from the worktree root, on a tree cleaned of bin and obj:
cd packages/rust && cargo run -q -p armonik-transport --features schema \
--example generate_schema | diff - \
../csharp/tools/ArmoniK.Api.TransportOptionsGenerator.Tests/Fixtures/http_config.schema.json
no difference: the fixture is the schema of this tree
dotnet run --project packages/csharp/tools/ArmoniK.Api.TransportOptionsGenerator -- \
--schema packages/csharp/tools/.../Fixtures/http_config.schema.json \
--output packages/csharp/tools/.../Fixtures/TransportOptions.cs
git diff --exit-code
clean: the committed class is the one this schema gives
dotnet test packages/csharp/tools/ArmoniK.Api.TransportOptionsGenerator.Tests
Passed! Failed: 0, Passed: 5, Skipped: 0, Total: 5
Comparing the generated file with a golden copy says the generator is reproducible. It
says nothing about whether the class in it writes a document the transport can read,
and that is the half a caller depends on.
So the tests set options on the class and read the document back: nothing set gives an
empty object, two options give those two and no empty third, and a value carrying a
quote, a backslash and a control character comes back through a parser unchanged. The
last of those walks every option by reflection rather than naming a few, since an
escape that closes its string early or swallows a character is worth catching wherever
it happens, and since a test naming options has to be edited every time the vocabulary
grows.
Verification, from the worktree root, on a tree cleaned of bin and obj:
dotnet test packages/csharp/tools/ArmoniK.Api.TransportOptionsGenerator.Tests
Passed! Failed: 0, Passed: 10, Skipped: 0, Total: 10
The Rust side accepts what the class writes, checked by hand: a TransportOptions
carrying sixteen options, ProxyPassword among them spelling a quote and a backslash,
emitted
{"AllowUnsafeConnection":"true","BackOffMultiplier":"1.5","ConnectTimeout":"30s",
"Endpoint":"http://localhost:5001","Http2KeepAliveInterval":"20s",
"InitialBackOff":"1s","MaxAttempts":"3","MaxBackOff":"5s","PoolIdleTimeout":"90s",
"RateLimit":"100/1s","TcpKeepalive":"30s","Timeout":"45s",
"UserAgent":"armonik-csharp/1.0","ProxyAddress":"http://proxy.example:3128",
"ProxyPassword":"p\"a\\ss","ProxyUsername":"user"}
on one line, which a throwaway example deserialising through serde_json read back as
an HttpConfig: endpoint, connect_timeout 30s, timeout 45s, pool_idle_timeout 90s,
user_agent, max_attempts 3, and the proxy with its username and its redacted password.
The generator throws rather than write a class with no property, since a schema that names nothing produces an options class a caller cannot use and cannot debug. Nothing said so, and a reader had only the line itself to go on. The test names the branch and its message, so the refusal is a decision on record rather than a guard nobody has exercised. Verification, from the worktree root, on a tree cleaned of bin and obj: dotnet test packages/csharp/tools/ArmoniK.Api.TransportOptionsGenerator.Tests Passed! Failed: 0, Passed: 11, Skipped: 0, Total: 11
Reading the options did two things at once: it walked the schema for the places that
name options, and it turned each name it found into a property. The walk was a local
function mutating a list its caller owned, so neither half could be read without the
other.
The walk is now PropertyBags, which yields every properties object the schema holds in
the order it holds them, and reading the options is the loop over what it yields:
refuse a name that is not the vocabulary's, refuse a value that is not text, keep the
first description of a name that comes back. Whether a bag comes from the root, from
an allOf entry or from one of its anyOf branches stops mattering at the point where it
never mattered.
The generated file does not move; the two helpers named along the way, IsText and
Description, are the conditions that were already there, spelled where they can be
read.
Verification, from the worktree root, on a tree cleaned of bin and obj:
dotnet build packages/csharp/tools/ArmoniK.Api.TransportOptionsGenerator
0 Warning(s), 0 Error(s)
dotnet run --project packages/csharp/tools/ArmoniK.Api.TransportOptionsGenerator -- \
--schema packages/csharp/tools/.../Fixtures/http_config.schema.json \
--output packages/csharp/tools/.../Fixtures/TransportOptions.cs
git diff --exit-code packages/csharp/tools/.../Fixtures
clean: the golden file is byte for byte the one this tree already carries
dotnet test packages/csharp/tools/ArmoniK.Api.TransportOptionsGenerator.Tests
Passed! Failed: 0, Passed: 11, Skipped: 0, Total: 11
Three loops that each hid their subject one layer down.
The loop writing the ToTransportJson calls reads a name out of every option and never
touches the description, so it iterates the names.
The check on an option name walked the string to find a character that disqualifies
it, which is the shape of All spelled the long way.
The command line moved its index in the loop header and again in the body, so an
option taking a value had to be read as an exception to the loop's own rule. A while
over one index moving in one place says it instead: reading an argument consumes it,
twice where an option carries a value.
No behaviour moves with them. The generated file is byte for byte the one already
committed, and the command line answers as before, down to naming '--schema' as
unexpected when it is the last argument and its value is missing.
Verification, from the worktree root, on a tree cleaned of bin and obj:
dotnet build packages/csharp/tools/ArmoniK.Api.TransportOptionsGenerator
0 Warning(s), 0 Error(s)
dotnet run --project packages/csharp/tools/ArmoniK.Api.TransportOptionsGenerator -- \
--schema packages/csharp/tools/.../Fixtures/http_config.schema.json \
--output packages/csharp/tools/.../Fixtures/TransportOptions.cs
git diff --exit-code packages/csharp/tools/.../Fixtures
clean
dotnet test packages/csharp/tools/ArmoniK.Api.TransportOptionsGenerator.Tests
Passed! Failed: 0, Passed: 11, Skipped: 0, Total: 11
--help 0, --schema alone 1, --bogus 1, an output directory that does not exist 3
Generator holds a Description helper and, nested in it, an Option whose positional
Description property hides that helper inside the record's own scope. Two names for
two different things, one of which cannot be reached from where the other lives.
The property keeps its name: it is the option's description, the emission code reads
it as option.Description, and there is nothing else it could be called. The helper is
the newcomer, and what it does is read a description out of a schema property, so it
says so, next to ReadOptions which reads the options out of a schema.
Verification, from the worktree root, on a tree cleaned of bin and obj:
dotnet build packages/csharp/tools/ArmoniK.Api.TransportOptionsGenerator
0 Warning(s), 0 Error(s)
dotnet run --project packages/csharp/tools/ArmoniK.Api.TransportOptionsGenerator -- \
--schema packages/csharp/tools/.../Fixtures/http_config.schema.json \
--output packages/csharp/tools/.../Fixtures/TransportOptions.cs
git diff --exit-code packages/csharp/tools/.../Fixtures
clean: not one emitted byte moves with the name
dotnet test packages/csharp/tools/ArmoniK.Api.TransportOptionsGenerator.Tests
Passed! Failed: 0, Passed: 11, Skipped: 0, Total: 11
wkirschenmann
force-pushed
the
wk/feat/csharp-options-generator
branch
from
August 9, 2026 10:52
ef0fd75 to
2857db1
Compare
The option vocabulary spells the Certificate Authority option CaCertPath, and the
snapshot the generator reads still spelled it CaCert. A caller filling in the class
would have named an option the transport does not have, and the transport would have
gone looking for a CA it was never given.
The suite did not notice, and cannot: the golden class is generated from the committed
snapshot, so the two agree with each other whatever the vocabulary says. Only a run
against the schema this tree prints tells them apart, which is what the snapshot's own
verification recipe does and what the gate on the committed schema will do.
Twenty-eight options still, one renamed, none added and none removed. Three of the
schema's descriptions are rewritten too, but all three describe an anyOf branch rather
than an option, and a branch documents a shape the generator deliberately does not
carry: the class documents options. So the class moves on the rename alone, in the
property and in the call that writes it.
Verification, from the worktree root, on a tree cleaned of bin and obj:
cd packages/rust && cargo run -q -p armonik-transport --features schema \
--example generate_schema | diff - \
../csharp/tools/ArmoniK.Api.TransportOptionsGenerator.Tests/Fixtures/http_config.schema.json
no difference: the fixture is the schema of this tree
dotnet build packages/csharp/tools/ArmoniK.Api.TransportOptionsGenerator
0 Warning(s), 0 Error(s)
dotnet run --project packages/csharp/tools/ArmoniK.Api.TransportOptionsGenerator -- \
--schema packages/csharp/tools/.../Fixtures/http_config.schema.json \
--output packages/csharp/tools/.../Fixtures/TransportOptions.cs
git diff --exit-code
clean: the committed class is the one this schema gives
dotnet test packages/csharp/tools/ArmoniK.Api.TransportOptionsGenerator.Tests
Passed! Failed: 0, Passed: 11, Skipped: 0, Total: 11
The transport reads what the class writes, checked by hand: a TransportOptions setting
seventeen options, CaCertPath among them, emitted
{"AllowUnsafeConnection":"true","BackOffMultiplier":"1.5",
"CaCertPath":"<a PEM certificate>","ConnectTimeout":"30s",
"Endpoint":"http://localhost:5001","Http2KeepAliveInterval":"20s",
"InitialBackOff":"1s","MaxAttempts":"3","MaxBackOff":"5s","PoolIdleTimeout":"90s",
"RateLimit":"100/1s","TcpKeepalive":"30s","Timeout":"45s",
"UserAgent":"armonik-csharp/1.0","ProxyAddress":"http://proxy.example:3128",
"ProxyPassword":"p\"a\\ss","ProxyUsername":"user"}
on one line, which a throwaway example deserialising through serde_json read back as
an HttpConfig with ca_cert set, alongside the timeouts, the retry policy and the proxy
credentials.
CaCertPath has to name a file that exists: reading the configuration reads the PEM and
parses it there and then, rather than at connection time, so a path that resolves
nowhere is refused while the document is being read. The round trip points it at a
certificate the repository already carries for its own tests.
|
wkirschenmann
added a commit
that referenced
this pull request
Aug 12, 2026
`github.head_ref` is a branch name, so `actions/checkout` resolves it to wherever the branch stands when the step runs rather than to the commit the run was triggered on. A run that waits in the queue while its branch moves therefore builds, and names, a commit that is not its own head. Seen on #745: 08:50:17 push 6df26e6, run 31304468328 starts 08:55:55 push f230436, run 31304689662 starts 09:08:46 run 31304468328 reaches its Context job, resolves 3.30.0-wkfeatcsharpoptionsgenerator.77.f2304365 - a commit that is not its head - and pushes it to nuget.org 09:09:11 run 31304689662, whose head really is f230436, resolves the same version and fails with 409 Conflict on ArmoniK.Api.Core The failed checks are cosmetic. What is not is that a run reported against 6df26e6 built and published f230436, so the run says nothing about the commit it is attached to. `github.event.pull_request.head.sha` is the head at the moment the event was delivered, and actions/checkout treats a 40-hex `ref` as a commit and checks it out detached. The C# and npm jobs move with the context job rather than being left behind: the version now names one specific commit, and those jobs have to build that one. Detached breaks the version generator, which reads `git rev-parse --abbrev-ref HEAD` (`config.to` in @aneoconsultingfr/generate-next-version@0.0.5) both as the pre-release label and as the end of the commit range it counts, and detached reads `HEAD`. The generator takes no argument or environment variable for it: the CLI declares only --language, --base and --edge, and cac rejects anything else. So the branch name is put back on the pinned commit with `git checkout -B`. Verified against the real generator on a synthetic repository tagged 3.29.3, with a branch whose tip has moved one commit past the pin: checkout the branch name 3.30.0-wkfeatcsharpoptionsgenerator.3.b44c3b9 pinned SHA, detached 3.30.0-head.2.2acb0df pinned SHA + checkout -B 3.30.0-wkfeatcsharpoptionsgenerator.2.2acb0df Only the last one names the pinned commit, and its commit count is the pinned commit's too. Re-run in the shape CI produces - fetch `+refs/heads/*:refs/remotes/origin/*` and `+refs/tags/*:refs/tags/*`, then force-checkout the SHA - where the tags the generator needs are present and `checkout -B` creates the local branch at the pinned commit rather than following `origin/<branch>`, leaving the working tree untouched. `fetch-depth: 0` is what keeps the tags available: actions/checkout 4.2.2 fetches every branch and every tag whenever the depth is 0, whatever `ref` holds, and refetches the bare SHA if it is not reachable - which is also why this works for a pull request from a fork, where `github.head_ref` names no branch of the base repository at all. Not verified: the workflow itself, which cannot be run from here. YAML and workflow schema checked with @action-validator/cli.
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.



Sits on
wk/feat/rust-ffi-skeleton, to merge first.Motivation
armonik-transportdescribes its flat option vocabulary as a JSON schema, and a C# caller needs aclass to fill in. A hand-written mirror would drift the day someone forgot it.
Description
A console tool under
packages/csharp/toolsreads the schema and writesTransportOptions:twenty-eight options, each a string defaulting to
"", documented from the schema's owndescriptions, plus
ToTransportJsonwriting the document the transport reads. The class is theunion of the options the schema names, at its root and in every
allOf/anyOfbranch, deduplicatedby name with the first description winning.
No validation is generated. The
anyOfgroups are tautological - one branch requires nothing - sothey document the accepted shapes rather than constrain them, and the transport stays the single
validator, refusing by option name.
ToTransportJsonomits an empty option, a missing key readingas the empty string, and writes its own JSON so that hosting the file costs no dependency. An option
the tool cannot spell stops it rather than reaching the compiler.
Testing
Adds 11. Five pin the generator against a committed schema snapshot and the class written from it;
the golden is compiled into the test project as well as read by it, which proves it is valid C# with
every escape closed. Five pin the document the class writes, the last walking every option by
reflection.
dotnet teston the test project, all green.The eleventh answers SonarCloud's always-true condition on the guard refusing a schema that names no
option. The condition is not always true - the suite drives 28 options through the
returntheengine called dead, which its analysis does not follow through a local function capturing the list
it fills - but that branch had no test, so it was unexercised in the sense a reader cares about, and
demonstrating the path is the better answer: the test asserts the message, not the exception type
alone. Sonar's other five findings are shape rather than behaviour, a schema walk and three loops
given names and expressions, and not one emitted byte moves with them - the fixtures diff empty
across the three commits, and the golden is still reproduced from the committed fixture.
Impact
Nothing existing changes; both projects are new and
IsPackable=false. They are deliberately out ofArmoniK.Api.sln, which CI restores andjb cleanupcoderewrites before requiring a clean diff,against a golden compared byte for byte; the
<auto-generated>banner is meant to keep thereformatter off it, but nothing here proves it does. They join the solution with the gate.
Additional Information
The fixture is a snapshot recording no Rust revision it came from, and nothing in
dotnet testcompares it to the live schema - the golden class is generated from the fixture, not from the Rust
source. The freshness gate arrives with the PR that commits the real schema.
Checklist