Skip to content

Commit 484f3b5

Browse files
rekhoffJasonAtClockworkjdetterlisandroctbfops
authored
Add .NET 10 support via NativeAOT-LLVM along side .NET 8 support (#4915)
# Description of changes Builds on #4741 to clean up and refine the NativeAOT-LLVM integration. The major changes: - **Standardized the repo on .NET 10 SDK** (`global.json` → `10.0.100`). The team is migrating to .NET 10 ahead of .NET 8 EOL. End users are unaffected as their SDK version is controlled by their own `global.json` emitted by `spacetime init`. - **Multi-target NuGet packages** instead of conditional single-target: - `SpacetimeDB.Runtime` now unconditionally targets `net8.0;net10.0` - `SpacetimeDB.BSATN.Runtime` now unconditionally targets `netstandard2.1;net8.0;net10.0` - NuGet packages ship both TFMs in a single `.nupkg`. No `EXPERIMENTAL_WASM_AOT` env var needed at pack time - `EXPERIMENTAL_WASM_AOT` define constant is set automatically when `TargetFramework == net10.0` - `ILCompiler.LLVM` package references conditioned on `TargetFramework == net10.0` (not env var) - **Centralized NativeAOT-LLVM build logic** in `SpacetimeDB.Runtime.props` and `SpacetimeDB.Runtime.targets`: - Moved `PublishTrimmed`, `SelfContained`, `WasmEnableThreads` into `.props` (previously duplicated across 4+ `.csproj` files) - Moved the `.wit`-stripping `UseWasiRuntimeOverlayWithoutComponentWit` target into `.targets` (was duplicated in every server `.csproj`) - Added `IlcLlvmTarget=wasm32-unknown-wasip1` override after ILCompiler.LLVM.targets import (previously passed as `/p:` arg) - Added `_InitializeWasiSdk` to `ObtainWasiSdk` BeforeTargets for .NET 10 compatibility - Consumer `.csproj` files no longer need any AOT boilerplate - **Refactored `csharp.rs` build logic** to support three build paths via `CsharpBuildPath` enum: - `Net8Jit` : stable .NET 8 path using `wasi-experimental` workload (Mono WASM) - `Net8Aot` : .NET 8 NativeAOT-LLVM (opt-in via `--native-aot` or `EXPERIMENTAL_WASM_AOT=1`) - `Net10Aot` : .NET 10 NativeAOT-LLVM (auto-detected when .NET 10 SDK is active) - Auto-detects .NET SDK version from `dotnet --version` (respects `global.json`) - Automatically sets `EXPERIMENTAL_WASM_AOT=1` for AOT paths so MSBuild conditionals activate - Unified `dotnet publish` command for all paths. Build-specific config handled by props/targets - Removed redundant `/p:IlcLlvmTarget` and `/p:WasmEnableThreads` CLI args (now in `.targets`) - **Refactored `init.rs`** for .NET version-aware scaffolding: - Added `--dotnet-version` CLI arg to explicitly select .NET 8 or 10 - Added `resolve_dotnet_major()` which auto-detects or prompts interactively when multiple SDKs are installed - .NET 10 auto-enables NativeAOT-LLVM (`--native-aot` not required) - .NET 10 template emits `<TargetFramework>net10.0</TargetFramework>` directly (no conditional) - .NET 8 AOT template adds `ILCompiler.LLVM 8.0.0-*` package refs gated on `EXPERIMENTAL_WASM_AOT=1` - Emits correct `global.json` per SDK version (`8.0.100` or `10.0.100`) - **Added `_initialize` call in `wasmtime_module.rs`**: NativeAOT-LLVM modules are WASI reactors that export `_initialize` to bootstrap the native runtime. This is called before preinit functions. Traditional .NET 8 WASI modules export `_start` instead and are unaffected. - **Updated `FFI.cs` `WasmImportLinkageAttribute` guard** to `#if EXPERIMENTAL_WASM_AOT && NET10_0_OR_GREATER`, ensuring the real attribute is only used when both the AOT flag and .NET 10+ TFM are active. The dummy shim is used for all other builds. - **Updated `csharp_aot_module.rs` smoketest**: - Auto-detects .NET SDK version and adjusts TFM accordingly - .NET 8 AOT: Windows-only (ILCompiler.LLVM 8.0.0-* not published for Linux) - .NET 10 AOT: Windows and Linux - Removed emscripten dependency (NativeAOT-LLVM uses WASI SDK, not emscripten) - **Updated CLI reference docs** to reflect `--native-aot` behavior change (not needed for .NET 10) and new `--dotnet-version` arg. This PR addresses several issues from #4741 review: 1. **NativeAOT-LLVM build logic was duplicated** in every server `.csproj` (4+ files had identical `PropertyGroup` and `Target` blocks). This is now centralized in the shipped `.props`/`.targets`. 2. **NuGet packages only shipped a single TFM**. The env var controlled which TFM was built, meaning you needed `EXPERIMENTAL_WASM_AOT=1` at pack time to get the `net10.0` DLL. Now both TFMs are always included. 3. **`spacetime init` emitted unnecessary conditional wrappers** in the generated `.csproj` for .NET 10 projects. Since the project is definitively targeting .NET 10, the TFM should be unconditional. 4. **`spacetime publish` passed MSBuild properties via `/p:` args** that are now handled by centralized `.targets`, simplifying the CLI code. # API and ABI breaking changes - `global.json` now requires .NET 10 SDK for repo development. All developers and CI agents need .NET SDK 10.0+ installed. - End user module projects are **not affected**. They continue to use whichever SDK their local `global.json` specifies. - .NET 8 JIT and .NET 8 AOT paths remain functional for end users until .NET 8 EOL. # Expected complexity level and risk 2 - The core risk is the same as #4741: future changes in the upstream NativeAOT-LLVM/WASI pipeline. Centralizing the build logic in `.props`/`.targets` reduces the surface area for breaking changes (one place to update vs. N consumer projects). The `.wit`-stripping workaround remains necessary until [dotnet/runtimelab#3144](dotnet/runtimelab#3144) is resolved. # Testing - Ran C# module publish + client connection tests for all three build paths: - .NET 8 JIT (`spacetime publish` without `--native-aot`) - .NET 8 AOT (`spacetime publish` with `EXPERIMENTAL_WASM_AOT=1` and .NET 8 SDK) - .NET 10 AOT (`spacetime publish` with .NET 10 SDK, auto-detected) - Verified `spacetime init --lang csharp` generates correct project structure for both .NET 8 and .NET 10 - Verified `spacetime init --lang csharp --dotnet-version 10` emits `net10.0` TFM and `10.0.100` in `global.json` - Verified C# regression tests complete successfully when ran under .NET 8 JIT, .NET 8 AOT, .NET 10 AOT - This required making local customizations to the repo versions of the regression tests, in order to conform to the configuration output by `spacetime init`, but the tests themselves remained identical. - Verified NuGet packages contain both `lib/net8.0/` and `lib/net10.0/` DLLs - Updated smoketest `test_build_csharp_module_aot` to work with both .NET 8 and .NET 10 SDK --------- Signed-off-by: Ryan <r.ekhoff@clockworklabs.io> Co-authored-by: Jason Larabie <jason@clockworklabs.io> Co-authored-by: John Detter <4099508+jdetter@users.noreply.github.com> Co-authored-by: Lisandro Crespo <lisandroct@gmail.com> Co-authored-by: Zeke Foppa <196249+bfops@users.noreply.github.com>
1 parent 3c87e69 commit 484f3b5

79 files changed

Lines changed: 2525 additions & 622 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ci.yml

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,13 @@ jobs:
184184
cd modules
185185
# the sdk-manifests on windows-latest are messed up, so we need to update them
186186
dotnet workload config --update-mode manifests
187-
dotnet workload update
187+
dotnet workload update --from-previous-sdk
188+
# Explicitly install wasi-experimental for .NET 8 SDK (needed for test_build_csharp_module)
189+
# Create temp global.json to target .NET 8 SDK for workload install
190+
$sdk8Json = '{"sdk":{"version":"8.0.400","rollForward":"latestFeature"}}'
191+
$sdk8Json | Out-File -FilePath global.json -Encoding utf8
192+
dotnet workload install wasi-experimental
193+
Remove-Item global.json
188194
189195
- name: Override NuGet packages
190196
shell: bash
@@ -1075,6 +1081,16 @@ jobs:
10751081
with:
10761082
global-json-file: global.json
10771083

1084+
- name: Install .NET workloads
1085+
run: |
1086+
dotnet workload config --update-mode manifests
1087+
dotnet workload update --from-previous-sdk
1088+
# Explicitly install wasi-experimental for .NET 8 SDK (needed for test_build_csharp_module)
1089+
# Create temp global.json to target .NET 8 SDK for workload install
1090+
echo '{"sdk":{"version":"8.0.100","rollForward":"latestFeature"}}' > global.json
1091+
dotnet workload install wasi-experimental
1092+
rm global.json
1093+
10781094
- name: Override NuGet packages
10791095
run: |
10801096
dotnet pack crates/bindings-csharp/BSATN.Runtime
@@ -1146,9 +1162,11 @@ jobs:
11461162
11471163
- name: Check quickstart-chat bindings are up to date
11481164
run: |
1149-
bash sdks/csharp/tools~/gen-quickstart.sh
1165+
for dotnet_version in 8 10; do
1166+
bash sdks/csharp/tools~/gen-quickstart.sh "$dotnet_version"
1167+
done
11501168
tools/check-diff.sh templates/chat-console-cs/module_bindings || {
1151-
echo 'Error: quickstart-chat bindings have changed. Please run `sdks/csharp/tools~/gen-quickstart.sh`.'
1169+
echo 'Error: quickstart-chat bindings have changed. Please run `sdks/csharp/tools~/gen-quickstart.sh 10`.'
11521170
exit 1
11531171
}
11541172
@@ -1170,6 +1188,21 @@ jobs:
11701188
- name: Run regression tests
11711189
run: |
11721190
bash sdks/csharp/tools~/run-regression-tests.sh
1191+
# Restore global.json symlinks (we replaced them with files to work around .NET 10 SDK bug)
1192+
# server is 5 levels deep from root, republishing dirs are 6 levels deep
1193+
if [ -f sdks/csharp/examples~/regression-tests/server/global.json ] && [ ! -L sdks/csharp/examples~/regression-tests/server/global.json ]; then
1194+
echo "Restoring symlink at server/global.json"
1195+
rm -f sdks/csharp/examples~/regression-tests/server/global.json
1196+
ln -s ../../../../../global.json sdks/csharp/examples~/regression-tests/server/global.json
1197+
fi
1198+
for dir in sdks/csharp/examples~/regression-tests/republishing/server-initial \
1199+
sdks/csharp/examples~/regression-tests/republishing/server-republish; do
1200+
if [ -f "$dir/global.json" ] && [ ! -L "$dir/global.json" ]; then
1201+
echo "Restoring symlink at $dir/global.json"
1202+
rm -f "$dir/global.json"
1203+
ln -s ../../../../../../global.json "$dir/global.json"
1204+
fi
1205+
done
11731206
tools/check-diff.sh sdks/csharp/examples~/regression-tests || {
11741207
echo 'Error: Bindings are dirty. Please run `sdks/csharp/tools~/gen-regression-tests.sh`.'
11751208
exit 1

crates/bindings-csharp/BSATN.Codegen/BSATN.Codegen.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
</PropertyGroup>
2121

2222
<ItemGroup>
23+
<!-- Keep the shipped analyzer loadable by the .NET 8 compiler host. -->
2324
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.3.1" PrivateAssets="all" />
2425
</ItemGroup>
2526

crates/bindings-csharp/BSATN.Codegen/Diag.cs

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -164,13 +164,16 @@ EnumDeclarationSyntax @enum
164164
@enum => @enum.Members[256]
165165
);
166166

167-
public static readonly ErrorDescriptor<INamedTypeSymbol> TaggedEnumInlineTuple =
167+
public static readonly ErrorDescriptor<(
168+
INamedTypeSymbol baseType,
169+
SyntaxNode location
170+
)> TaggedEnumInlineTuple =
168171
new(
169172
group,
170173
"Tagged enum variants must be declared with inline tuples",
171-
baseType =>
172-
$"{baseType} does not have the expected format SpacetimeDB.TaggedEnum<(TVariant1 v1, ..., TVariantN vN)>.",
173-
baseType => baseType
174+
ctx =>
175+
$"{ctx.baseType} does not have the expected format SpacetimeDB.TaggedEnum<(TVariant1 v1, ..., TVariantN vN)>.",
176+
ctx => ctx.location
174177
);
175178

176179
public static readonly ErrorDescriptor<IFieldSymbol> TaggedEnumField =

crates/bindings-csharp/BSATN.Codegen/Type.cs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -563,7 +563,13 @@ public BaseTypeDeclaration(GeneratorAttributeSyntaxContext context, DiagReporter
563563
is not INamedTypeSymbol { IsTupleType: true, TupleElements: var taggedEnumVariants }
564564
)
565565
{
566-
diag.Report(ErrorDescriptor.TaggedEnumInlineTuple, type.BaseType);
566+
diag.Report(
567+
ErrorDescriptor.TaggedEnumInlineTuple,
568+
(
569+
type.BaseType,
570+
(SyntaxNode?)typeSyntax.BaseList?.Types.FirstOrDefault()?.Type ?? typeSyntax
571+
)
572+
);
567573
}
568574

569575
if (fields.FirstOrDefault() is { } field)

crates/bindings-csharp/BSATN.Runtime.Tests/BSATN.Runtime.Tests.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
</PropertyGroup>
66

77
<PropertyGroup>
8-
<TargetFrameworks>net8.0</TargetFrameworks>
8+
<TargetFrameworks>net8.0;net10.0</TargetFrameworks>
99
<RootNamespace>SpacetimeDB</RootNamespace>
1010
<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
1111
</PropertyGroup>

crates/bindings-csharp/BSATN.Runtime/BSATN.Runtime.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010
<PropertyGroup>
1111
<!-- Note: the binary produced by this package is used in Unity too, which is limited to .NET Standard 2.1. -->
12-
<TargetFrameworks>netstandard2.1;net8.0</TargetFrameworks>
12+
<TargetFrameworks>netstandard2.1;net8.0;net10.0</TargetFrameworks>
1313
<RootNamespace>SpacetimeDB</RootNamespace>
1414
<!-- You can enable this when debugging codegen problems. Outputs in obj/debug/[version]/generated. -->
1515
<!-- <EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles> -->

crates/bindings-csharp/Codegen.Tests/Codegen.Tests.csproj

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
</PropertyGroup>
77

88
<PropertyGroup>
9-
<TargetFramework>net8.0</TargetFramework>
9+
<TargetFrameworks>net8.0;net10.0</TargetFrameworks>
1010
<RootNamespace>SpacetimeDB.Codegen.Tests</RootNamespace>
1111
<DefaultItemExcludes>$(DefaultItemExcludes);fixtures/**/*</DefaultItemExcludes>
1212
</PropertyGroup>
@@ -18,17 +18,29 @@
1818

1919
<ItemGroup>
2020
<PackageReference Include="CSharpier.Core" Version="0.28.2" />
21-
<PackageReference Include="Microsoft.CodeAnalysis.Common" Version="4.10.0" />
22-
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="4.10.0" />
23-
<PackageReference Include="Microsoft.CodeAnalysis.Workspaces.MSBuild" Version="4.10.0" />
24-
<PackageReference Include="Microsoft.Extensions.Logging" Version="8.0.0" />
2521
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.6.0" />
2622
<PackageReference Include="Verify.SourceGenerators" Version="2.4.3" />
2723
<PackageReference Include="Verify.XUnit" Version="26.4.5" />
2824
<PackageReference Include="xunit" Version="2.9.0" />
2925
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.1" PrivateAssets="all" />
3026
</ItemGroup>
3127

28+
<ItemGroup Condition="$(TargetFramework.StartsWith('net8.'))">
29+
<PackageReference Include="Microsoft.CodeAnalysis.Common" Version="4.14.0" />
30+
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="4.14.0" />
31+
<PackageReference Include="Microsoft.CodeAnalysis.Workspaces.MSBuild" Version="4.14.0" />
32+
<PackageReference Include="Microsoft.Build.Tasks.Core" Version="17.11.48" PrivateAssets="all" />
33+
<PackageReference Include="Microsoft.Extensions.Logging" Version="9.0.0" />
34+
<PackageReference Include="System.Security.Cryptography.Xml" Version="10.0.9" PrivateAssets="all" />
35+
</ItemGroup>
36+
37+
<ItemGroup Condition="$(TargetFramework.StartsWith('net10.'))">
38+
<PackageReference Include="Microsoft.CodeAnalysis.Common" Version="5.0.0" />
39+
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="5.0.0" />
40+
<PackageReference Include="Microsoft.CodeAnalysis.Workspaces.MSBuild" Version="5.0.0" />
41+
<PackageReference Include="Microsoft.Extensions.Logging" Version="9.0.0" />
42+
</ItemGroup>
43+
3244
<ItemGroup>
3345
<ProjectReference Include="../Codegen/Codegen.csproj" />
3446
</ItemGroup>

crates/bindings-csharp/Codegen.Tests/Tests.cs

Lines changed: 21 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -26,19 +26,19 @@ public static class GeneratorSnapshotTests
2626

2727
record struct StepOutput(string Key, IncrementalStepRunReason Reason, object Value);
2828

29-
class Fixture
29+
private class Fixture
3030
{
3131
private readonly string projectDir;
32-
private readonly CSharpCompilation sampleCompilation;
32+
public CSharpCompilation SampleCompilation { get; }
33+
public CSharpParseOptions ParseOptions { get; }
3334

3435
public Fixture(string projectDir, CSharpCompilation sampleCompilation)
3536
{
3637
this.projectDir = projectDir;
37-
this.sampleCompilation = sampleCompilation;
38+
SampleCompilation = sampleCompilation;
39+
ParseOptions = (CSharpParseOptions)sampleCompilation.SyntaxTrees.First().Options;
3840
}
3941

40-
public CSharpCompilation SampleCompilation => sampleCompilation;
41-
4242
public static async Task<Fixture> Compile(string name)
4343
{
4444
var projectDir = Path.Combine(GetProjectDir(), "fixtures", name);
@@ -53,7 +53,7 @@ public Task Verify(string fileName, object target) =>
5353

5454
private static CSharpGeneratorDriver CreateDriver(
5555
IIncrementalGenerator generator,
56-
LanguageVersion languageVersion
56+
CSharpParseOptions parseOptions
5757
)
5858
{
5959
return CSharpGeneratorDriver.Create(
@@ -62,39 +62,39 @@ LanguageVersion languageVersion
6262
disabledOutputs: IncrementalGeneratorOutputKind.None,
6363
trackIncrementalGeneratorSteps: true
6464
),
65-
// Make sure that generated files are parsed with the same language version.
66-
parseOptions: new(languageVersion)
65+
// Make sure generated files are parsed with the same language version and feature flags.
66+
parseOptions: parseOptions
6767
);
6868
}
6969

7070
private async Task<IEnumerable<SyntaxTree>> RunAndCheckGenerator(
7171
IIncrementalGenerator generator
7272
)
7373
{
74-
var driver = CreateDriver(generator, sampleCompilation.LanguageVersion);
74+
var driver = CreateDriver(generator, ParseOptions);
7575

7676
// Store the new driver instance - it contains the results and the cache.
77-
var driverAfterGen = driver.RunGenerators(sampleCompilation);
77+
var driverAfterGen = driver.RunGenerators(SampleCompilation);
7878
var genResult = driverAfterGen.GetRunResult();
7979

8080
// Verify the generated code against the snapshots.
8181
await Verify(generator.GetType().Name, genResult);
8282

83-
CheckCacheWorking(sampleCompilation, driverAfterGen);
83+
CheckCacheWorking(SampleCompilation, driverAfterGen);
8484

8585
return genResult.GeneratedTrees;
8686
}
8787

8888
public GeneratorDriverRunResult RunGeneratorAndGetResult(IIncrementalGenerator generator)
8989
{
90-
var driver = CreateDriver(generator, sampleCompilation.LanguageVersion);
91-
return driver.RunGenerators(sampleCompilation).GetRunResult();
90+
var driver = CreateDriver(generator, ParseOptions);
91+
return driver.RunGenerators(SampleCompilation).GetRunResult();
9292
}
9393

9494
public async Task<CSharpCompilation> RunAndCheckGenerators(
9595
params IIncrementalGenerator[] generators
9696
) =>
97-
sampleCompilation.AddSyntaxTrees(
97+
SampleCompilation.AddSyntaxTrees(
9898
(await Task.WhenAll(generators.Select(RunAndCheckGenerator))).SelectMany(output =>
9999
output
100100
)
@@ -241,10 +241,7 @@ public static async Task TypeAndModuleGeneratorsOnServer()
241241
// make sure a downstream "user" file that references SpacetimeDB.Local doesn't trigger CS0436.
242242
var userCode =
243243
"namespace User; public sealed class UseLocal { public SpacetimeDB.Local Db; }";
244-
var userTree = CSharpSyntaxTree.ParseText(
245-
userCode,
246-
new CSharpParseOptions(compilationAfterGen.LanguageVersion)
247-
);
244+
var userTree = CSharpSyntaxTree.ParseText(userCode, fixture.ParseOptions);
248245
var compilationWithUserCode = compilationAfterGen.AddSyntaxTrees(userTree);
249246
AssertNoCs0436Diagnostics(compilationWithUserCode);
250247
}
@@ -332,8 +329,11 @@ public static void @params(ProcedureContext ctx)
332329
}
333330
""";
334331

335-
var parseOptions = new CSharpParseOptions(fixture.SampleCompilation.LanguageVersion);
336-
var tree = CSharpSyntaxTree.ParseText(source, parseOptions, path: "KeywordNames.cs");
332+
var tree = CSharpSyntaxTree.ParseText(
333+
source,
334+
fixture.ParseOptions,
335+
path: "KeywordNames.cs"
336+
);
337337
var compilation = fixture.SampleCompilation.AddSyntaxTrees(tree);
338338

339339
var driver = CSharpGeneratorDriver.Create(
@@ -345,7 +345,7 @@ public static void @params(ProcedureContext ctx)
345345
disabledOutputs: IncrementalGeneratorOutputKind.None,
346346
trackIncrementalGeneratorSteps: true
347347
),
348-
parseOptions: parseOptions
348+
parseOptions: fixture.ParseOptions
349349
);
350350

351351
var runResult = driver.RunGenerators(compilation).GetRunResult();

crates/bindings-csharp/Codegen.Tests/fixtures/diag/diag.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<Project Sdk="Microsoft.NET.Sdk">
22

33
<PropertyGroup>
4-
<TargetFramework>net8.0</TargetFramework>
4+
<TargetFramework>net10.0</TargetFramework>
55
<ImplicitUsings>enable</ImplicitUsings>
66
</PropertyGroup>
77

crates/bindings-csharp/Codegen.Tests/fixtures/diag/snapshots/Module#FFI.verified.cs

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)