diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 317f378..92d89df 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -7,6 +7,16 @@ updates: open-pull-requests-limit: 10 labels: - "dependencies" + ignore: + # The source generator references Roslyn and runs inside the consumer's + # compiler. Building against a newer major (5.x ships with the .NET 10 SDK) + # makes the generator emit CS9057 and silently NOT run for consumers on the + # .NET 8/9 SDKs. Stay on the 4.x/3.x line "for SDK compatibility"; allow + # only minor/patch bumps. + - dependency-name: "Microsoft.CodeAnalysis.CSharp" + update-types: ["version-update:semver-major"] + - dependency-name: "Microsoft.CodeAnalysis.Analyzers" + update-types: ["version-update:semver-major"] groups: microsoft: patterns: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a59f697..a699cd0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,14 +49,14 @@ jobs: - name: Upload Test Results if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: test-results-${{ matrix.os }}-net${{ matrix.dotnet }} path: TestResults/ - name: Upload Coverage if: matrix.os == 'ubuntu-latest' && matrix.dotnet == '10.0.x' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: coverage-report path: TestResults/**/coverage.cobertura.xml @@ -92,6 +92,27 @@ jobs: exit 1 fi + package-smoke: + name: Packaged Consumer Smoke Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: | + 8.0.x + 9.0.x + 10.0.x + + # Packs the real packages, installs them into a throwaway consumer, and + # asserts the source generator is delivered and runs (direct + DI flows). + # This is the only check that exercises packaging the way a consumer does; + # the unit/integration projects do not wire the generator as an analyzer. + - name: Pack + install + run a real consumer + run: ./eng/package-smoke-test.sh + format-check: name: Format Check runs-on: ubuntu-latest @@ -176,7 +197,7 @@ jobs: --artifacts benchmark-results - name: Upload Benchmark Results - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: benchmark-results path: benchmark-results/ diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index b874914..a653a9c 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -22,7 +22,7 @@ jobs: - uses: actions/checkout@v4 - name: Setup Pages - uses: actions/configure-pages@v5 + uses: actions/configure-pages@v6 - name: Build with Jekyll uses: actions/jekyll-build-pages@v1 @@ -31,7 +31,7 @@ jobs: destination: ./_site - name: Upload artifact - uses: actions/upload-pages-artifact@v3 + uses: actions/upload-pages-artifact@v5 deploy: environment: @@ -42,4 +42,4 @@ jobs: steps: - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v4 + uses: actions/deploy-pages@v5 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4e84dd5..329fdcb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -52,6 +52,16 @@ jobs: echo "Packages created:" ls -la nupkg/ + - name: Verify generator is bundled in the meta-package + run: | + PKG="nupkg/OpenAutoMapper.${{ env.VERSION }}.nupkg" + if ! unzip -l "$PKG" | grep -q "analyzers/dotnet/cs/OpenAutoMapper.Generator.dll"; then + echo "::error::$PKG does not contain the source generator (analyzers/dotnet/cs/OpenAutoMapper.Generator.dll). Refusing to publish a package that would generate nothing for consumers." + unzip -l "$PKG" + exit 1 + fi + echo "OK: source generator is bundled in $PKG" + - name: Generate SBOM run: | dotnet tool install -g Microsoft.Sbom.DotNetTool || true @@ -75,7 +85,7 @@ jobs: --skip-duplicate - name: Create GitHub Release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@v3 with: files: | nupkg/*.*nupkg diff --git a/Directory.Build.props b/Directory.Build.props index 404f179..4340b1f 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -11,6 +11,8 @@ + + 1.0.1 D. Ivahno OpenAutoMapper MIT diff --git a/Directory.Packages.props b/Directory.Packages.props index 54c2973..5826956 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -11,19 +11,19 @@ - + - + - + - - - + + + @@ -42,7 +42,7 @@ - + diff --git a/eng/package-smoke-test.sh b/eng/package-smoke-test.sh new file mode 100755 index 0000000..fc88ab4 --- /dev/null +++ b/eng/package-smoke-test.sh @@ -0,0 +1,149 @@ +#!/usr/bin/env bash +# +# Packaged-consumer smoke test. +# +# Packs the real NuGet packages, installs them into a throwaway consumer project, +# and asserts that the source generator is actually DELIVERED and RUNS — i.e. that +# `dotnet add package OpenAutoMapper` produces working mapping code, and that +# resolving IMapper through the DI package works. +# +# This is the test that would have caught the 1.0.0 packaging bug (generator never +# shipped to consumers) and the DI factory bug (MapperFactoryWithServiceCtor never +# registered, so IMapper resolution threw). The in-repo test projects do NOT wire +# the generator as an analyzer the way a real consumer does, so they cannot catch +# these regressions. +# +# Usage: eng/package-smoke-test.sh +# Requires: dotnet SDK on PATH. Network access to nuget.org (for the BCL packages). + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +WORK="$(mktemp -d)" +FEED="$WORK/feed" +CONSUMER="$WORK/consumer" +CONFIG="${CONFIGURATION:-Release}" +trap 'rm -rf "$WORK"' EXIT + +echo "==> Building solution, then packing to local feed: $FEED" +mkdir -p "$FEED" +# Build once, then pack with --no-build (exactly what release.yml does). This is +# deterministic: with --no-build there are no parallel per-TFM inner builds during +# pack, so pack's collection of the static None analyzer item cannot race the +# generator's build. (Plain `dotnet pack` rebuilds during pack and intermittently +# drops the analyzer when the multi-TFM inner builds race the file collection.) +if ! dotnet build "$REPO_ROOT/OpenAutoMapper.slnx" -c "$CONFIG" >/dev/null; then + echo "FAIL: solution build failed" + exit 1 +fi +for proj in \ + OpenAutoMapper.Abstractions \ + OpenAutoMapper.Core \ + OpenAutoMapper \ + OpenAutoMapper.DependencyInjection; do + if ! dotnet pack "$REPO_ROOT/src/$proj/$proj.csproj" -c "$CONFIG" --no-build -o "$FEED" >/dev/null; then + echo "FAIL: dotnet pack --no-build failed for $proj" + exit 1 + fi +done + +PKG_VERSION="$(ls "$FEED"/OpenAutoMapper.[0-9]*.nupkg 2>/dev/null | head -1 | sed -E 's/.*OpenAutoMapper\.([0-9][^/]*)\.nupkg/\1/')" +META_PKG="$FEED/OpenAutoMapper.$PKG_VERSION.nupkg" +if [ -z "$PKG_VERSION" ] || [ ! -f "$META_PKG" ]; then + echo "FAIL: OpenAutoMapper package was not produced in $FEED" + ls -la "$FEED" || true + exit 1 +fi +echo "==> Packed version: $PKG_VERSION" + +# Assert the analyzer DLL is physically present in the meta-package. +echo "==> Verifying analyzer is bundled in the OpenAutoMapper package" +if ! unzip -l "$META_PKG" | grep -q "analyzers/dotnet/cs/OpenAutoMapper.Generator.dll"; then + echo "FAIL: $META_PKG exists but does not contain analyzers/dotnet/cs/OpenAutoMapper.Generator.dll" + exit 1 +fi + +echo "==> Creating consumer project: $CONSUMER" +mkdir -p "$CONSUMER" +cat > "$CONSUMER/nuget.config" < + + + + + + + +EOF + +cat > "$CONSUMER/consumer.csproj" < + + Exe + net10.0 + enable + enable + + + + + + + + + +EOF + +cat > "$CONSUMER/Program.cs" <<'EOF' +using OpenAutoMapper; +using Microsoft.Extensions.DependencyInjection; + +public class Order { public int Id { get; set; } public string CustomerName { get; set; } = ""; public decimal Total { get; set; } } +public class OrderDto { public int Id { get; set; } public string CustomerName { get; set; } = ""; public decimal Total { get; set; } } +public class OrderProfile : Profile { public OrderProfile() => CreateMap(); } + +public static class Program +{ + public static int Main() + { + // 1) Direct MapperConfiguration flow (README quick start). + var config = new MapperConfiguration(cfg => cfg.AddProfile()); + var mapper = config.CreateMapper(); + var dto = mapper.Map(new Order { Id = 1, CustomerName = "Jane Doe", Total = 49.99m }); + if (dto.Id != 1 || dto.CustomerName != "Jane Doe" || dto.Total != 49.99m) + { + System.Console.Error.WriteLine("FAIL: direct mapping produced wrong result"); + return 1; + } + + // 2) DI flow — resolving IMapper exercises CreateMapper(serviceCtor). + var sp = new ServiceCollection() + .AddAutoMapper(cfg => cfg.CreateMap(), typeof(Program).Assembly) + .BuildServiceProvider(); + var diMapper = sp.GetRequiredService(); + var diDto = diMapper.Map(new Order { Id = 2, CustomerName = "DI", Total = 10m }); + if (diDto.Id != 2 || diDto.CustomerName != "DI") + { + System.Console.Error.WriteLine("FAIL: DI mapping produced wrong result"); + return 1; + } + + System.Console.WriteLine("SMOKE OK: direct + DI mapping work from packaged generator"); + return 0; + } +} +EOF + +echo "==> Building and running consumer (generator must run as a real analyzer)" +cd "$CONSUMER" +if ! dotnet run -c "$CONFIG" 2>&1 | tee "$WORK/run.log"; then + echo "FAIL: consumer run failed — generator likely not delivered" + exit 1 +fi + +if ! grep -q "SMOKE OK" "$WORK/run.log"; then + echo "FAIL: consumer did not print success marker" + exit 1 +fi + +echo "==> PASS: packaged-consumer smoke test succeeded" diff --git a/src/OpenAutoMapper.DependencyInjection/OpenAutoMapper.DependencyInjection.csproj b/src/OpenAutoMapper.DependencyInjection/OpenAutoMapper.DependencyInjection.csproj index 8499235..aee5c9d 100644 --- a/src/OpenAutoMapper.DependencyInjection/OpenAutoMapper.DependencyInjection.csproj +++ b/src/OpenAutoMapper.DependencyInjection/OpenAutoMapper.DependencyInjection.csproj @@ -7,7 +7,16 @@ - + + diff --git a/src/OpenAutoMapper.Generator/Pipeline/MapperImplEmitter.cs b/src/OpenAutoMapper.Generator/Pipeline/MapperImplEmitter.cs index eab2149..d55004e 100644 --- a/src/OpenAutoMapper.Generator/Pipeline/MapperImplEmitter.cs +++ b/src/OpenAutoMapper.Generator/Pipeline/MapperImplEmitter.cs @@ -117,10 +117,13 @@ public static string EmitFactoryInitializer() sb.AppendLine("// Register the generated mapper factory so that MapperConfiguration.CreateMapper() works."); sb.AppendLine("// Uses reflection to set the internal static property since the generator cannot reference Core directly."); sb.AppendLine("var configType = typeof(global::OpenAutoMapper.MapperConfiguration);"); - sb.AppendLine("var factoryProperty = configType.GetProperty(\"MapperFactory\","); + sb.AppendLine("const System.Reflection.BindingFlags flags ="); sb.Indent(); - sb.AppendLine("System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.NonPublic);"); + sb.AppendLine("System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.NonPublic;"); sb.Unindent(); + sb.AppendLine(); + sb.AppendLine("// Parameterless factory: MapperConfiguration.CreateMapper()."); + sb.AppendLine("var factoryProperty = configType.GetProperty(\"MapperFactory\", flags);"); sb.AppendLine("if (factoryProperty is not null)"); sb.OpenBrace(); sb.AppendLine("factoryProperty.SetValue(null, new global::System.Func("); @@ -128,6 +131,16 @@ public static string EmitFactoryInitializer() sb.AppendLine("config => new OpenAutoMapperImpl()));"); sb.Unindent(); sb.CloseBrace(); + sb.AppendLine(); + sb.AppendLine("// Service-constructor factory: MapperConfiguration.CreateMapper(serviceCtor), used by AddAutoMapper() DI."); + sb.AppendLine("var factoryWithCtorProperty = configType.GetProperty(\"MapperFactoryWithServiceCtor\", flags);"); + sb.AppendLine("if (factoryWithCtorProperty is not null)"); + sb.OpenBrace(); + sb.AppendLine("factoryWithCtorProperty.SetValue(null, new global::System.Func, global::OpenAutoMapper.IMapper>("); + sb.Indent(); + sb.AppendLine("(config, serviceCtor) => new OpenAutoMapperImpl()));"); + sb.Unindent(); + sb.CloseBrace(); sb.CloseBrace(); diff --git a/src/OpenAutoMapper/OpenAutoMapper.csproj b/src/OpenAutoMapper/OpenAutoMapper.csproj index ccaa61e..8464ce5 100644 --- a/src/OpenAutoMapper/OpenAutoMapper.csproj +++ b/src/OpenAutoMapper/OpenAutoMapper.csproj @@ -14,5 +14,47 @@ ReferenceOutputAssembly="false" /> + + + <_GeneratorAssemblyPath>$(MSBuildThisFileDirectory)../OpenAutoMapper.Generator/bin/$(Configuration)/netstandard2.0/OpenAutoMapper.Generator.dll + <_StagedGeneratorDir>$(BaseIntermediateOutputPath)gen-analyzer + <_StagedGeneratorPath>$(_StagedGeneratorDir)/OpenAutoMapper.Generator.dll + + + + + + + + + + + + diff --git a/tests/OpenAutoMapper.Generator.Tests/GeneratorSnapshotTests.MapperImpl.cs b/tests/OpenAutoMapper.Generator.Tests/GeneratorSnapshotTests.MapperImpl.cs index ab17713..62ac196 100644 --- a/tests/OpenAutoMapper.Generator.Tests/GeneratorSnapshotTests.MapperImpl.cs +++ b/tests/OpenAutoMapper.Generator.Tests/GeneratorSnapshotTests.MapperImpl.cs @@ -96,6 +96,26 @@ public class TestProfile : Profile { public TestProfile() { CreateMap s.Contains("OpenAutoMapperFactoryInit")); } + [Fact] + public void MapperImpl_FactoryInitializer_RegistersBothFactories() + { + // Regression: the factory initializer must register BOTH MapperFactory + // (CreateMapper()) and MapperFactoryWithServiceCtor (CreateMapper(serviceCtor)). + // The DI package resolves IMapper via CreateMapper(serviceCtor); when only the + // parameterless factory was registered, AddAutoMapper() worked for config but + // threw "No mapper factory registered" the moment IMapper was actually resolved. + var source = @" +using OpenAutoMapper; +namespace TestApp; +public class Source { public int Id { get; set; } } +public class Dest { public int Id { get; set; } } +public class TestProfile : Profile { public TestProfile() { CreateMap(); } } +"; + var (_, generatedSources) = TestHelper.RunGenerator(source); + generatedSources.Should().Contain(s => s.Contains("MapperFactory")); + generatedSources.Should().Contain(s => s.Contains("MapperFactoryWithServiceCtor")); + } + [Fact] public void MapperImpl_NonGenericMapOverloads() {