From dee85c9968bcad2087e58faaa9cf551fec46af2f Mon Sep 17 00:00:00 2001 From: Nitesh Jaiswal Date: Sat, 25 Apr 2026 14:47:24 +0545 Subject: [PATCH 1/4] Support LicenseKey via environment variable fallback LicenseKey now falls back to the AUTOMAPPER_LICENSE_KEY environment variable if not explicitly set. Added comprehensive unit and integration tests to verify this behavior. Updated test project dependencies to support DI and logging in tests. --- .../MapperConfigurationExpression.cs | 8 +- .../LicenseKeyEnvironmentVariableTests.cs | 208 ++++++++++++++++++ 2 files changed, 215 insertions(+), 1 deletion(-) create mode 100644 src/UnitTests/Licensing/LicenseKeyEnvironmentVariableTests.cs diff --git a/src/AutoMapper/Configuration/MapperConfigurationExpression.cs b/src/AutoMapper/Configuration/MapperConfigurationExpression.cs index 6bfebeadfe..c71ba333fb 100644 --- a/src/AutoMapper/Configuration/MapperConfigurationExpression.cs +++ b/src/AutoMapper/Configuration/MapperConfigurationExpression.cs @@ -127,7 +127,13 @@ void IGlobalConfigurationExpression.Validator(Validator validator) => /// int IGlobalConfigurationExpression.RecursiveQueriesMaxDepth { get; set; } - public string LicenseKey { get; set; } + private string _licenseKey; + + public string LicenseKey + { + get => _licenseKey ?? Environment.GetEnvironmentVariable("AUTOMAPPER_LICENSE_KEY"); + set => _licenseKey = value; + } public ServiceLifetime ServiceLifetime { get; set; } = ServiceLifetime.Transient; diff --git a/src/UnitTests/Licensing/LicenseKeyEnvironmentVariableTests.cs b/src/UnitTests/Licensing/LicenseKeyEnvironmentVariableTests.cs new file mode 100644 index 0000000000..27f135cc09 --- /dev/null +++ b/src/UnitTests/Licensing/LicenseKeyEnvironmentVariableTests.cs @@ -0,0 +1,208 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace AutoMapper.UnitTests.Licensing; + +public class LicenseKeyEnvironmentVariableTests +{ + private const string EnvVarName = "AUTOMAPPER_LICENSE_KEY"; + + #region Environment Variable Auto-Detection Tests + + [Fact] + public void LicenseKey_ReadsFromEnvironmentVariable_WhenNotExplicitlySet() + { + const string expectedKey = "test-license-key-12345"; + Environment.SetEnvironmentVariable(EnvVarName, expectedKey); + + try + { + var config = new MapperConfigurationExpression(); + + config.LicenseKey.ShouldBe(expectedKey); + } + finally + { + Environment.SetEnvironmentVariable(EnvVarName, null); + } + } + + [Fact] + public void LicenseKey_ReturnsNull_WhenEnvironmentVariableNotSet() + { + Environment.SetEnvironmentVariable(EnvVarName, null); + + var config = new MapperConfigurationExpression(); + + config.LicenseKey.ShouldBeNull(); + } + + #endregion + + #region Backward Compatibility - Old Way Tests + + [Fact] + public void LicenseKey_SupportsOldWay_DirectAssignment() + { + const string licenseKey = "old-way-explicit-key"; + var config = new MapperConfigurationExpression(); + + config.LicenseKey = licenseKey; + + config.LicenseKey.ShouldBe(licenseKey); + } + + [Fact] + public void LicenseKey_PrioritizesExplicitValue_OverEnvironmentVariable() + { + const string envKey = "env-license-key"; + const string explicitKey = "explicit-license-key"; + Environment.SetEnvironmentVariable(EnvVarName, envKey); + + try + { + var config = new MapperConfigurationExpression(); + + config.LicenseKey = explicitKey; + + config.LicenseKey.ShouldBe(explicitKey); + config.LicenseKey.ShouldNotBe(envKey); + } + finally + { + Environment.SetEnvironmentVariable(EnvVarName, null); + } + } + + [Fact] + public void LicenseKey_OldWayOverridesEnvironmentVariable_InConfigAction() + { + const string envKey = "env-license-key"; + const string explicitKey = "explicit-override-key"; + Environment.SetEnvironmentVariable(EnvVarName, envKey); + + try + { + var config = new MapperConfigurationExpression(); + + config.LicenseKey = explicitKey; + + config.LicenseKey.ShouldBe(explicitKey); + } + finally + { + Environment.SetEnvironmentVariable(EnvVarName, null); + } + } + + #endregion + + #region Integration Tests - Old Way with DI + + [Fact] + public void AddAutoMapper_SupportsOldWay_ExplicitLicenseKey() + { + const string licenseKey = "old-way-integration-key"; + MapperConfigurationExpression capturedCfg = null; + + var services = new ServiceCollection(); + services.AddLogging(); + services.AddAutoMapper(cfg => + { + cfg.LicenseKey = licenseKey; + cfg.CreateMap(); + capturedCfg = (MapperConfigurationExpression)cfg; + }); + + var serviceProvider = services.BuildServiceProvider(); + var mapper = serviceProvider.GetRequiredService(); + + var result = mapper.Map(new TestSource { Name = "Test" }); + + result.ShouldNotBeNull(); + result.Name.ShouldBe("Test"); + capturedCfg.LicenseKey.ShouldBe(licenseKey); + } + + [Fact] + public void AddAutoMapper_UsesEnvironmentVariable_WhenNoExplicitKeySet() + { + const string licenseKey = "env-integration-key"; + Environment.SetEnvironmentVariable(EnvVarName, licenseKey); + MapperConfigurationExpression capturedCfg = null; + + try + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddAutoMapper(cfg => + { + cfg.CreateMap(); + capturedCfg = (MapperConfigurationExpression)cfg; + }); + + var serviceProvider = services.BuildServiceProvider(); + var mapper = serviceProvider.GetRequiredService(); + + var result = mapper.Map(new TestSource { Name = "Test" }); + + result.ShouldNotBeNull(); + result.Name.ShouldBe("Test"); + capturedCfg.LicenseKey.ShouldBe(licenseKey); + } + finally + { + Environment.SetEnvironmentVariable(EnvVarName, null); + } + } + + [Fact] + public void AddAutoMapper_OldWayTakesPrecedence_OverEnvironmentVariable() + { + const string envKey = "env-license-key"; + const string explicitKey = "explicit-license-key-from-old-way"; + Environment.SetEnvironmentVariable(EnvVarName, envKey); + MapperConfigurationExpression capturedCfg = null; + + try + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddAutoMapper(cfg => + { + cfg.LicenseKey = explicitKey; + cfg.CreateMap(); + capturedCfg = (MapperConfigurationExpression)cfg; + }); + + var serviceProvider = services.BuildServiceProvider(); + var mapper = serviceProvider.GetRequiredService(); + + var result = mapper.Map(new TestSource { Name = "Test" }); + + result.ShouldNotBeNull(); + result.Name.ShouldBe("Test"); + capturedCfg.LicenseKey.ShouldBe(explicitKey); + capturedCfg.LicenseKey.ShouldNotBe(envKey); + } + finally + { + Environment.SetEnvironmentVariable(EnvVarName, null); + } + } + + #endregion + + #region Test Helper Classes + + private class TestSource + { + public string Name { get; set; } + } + + private class TestDestination + { + public string Name { get; set; } + } + + #endregion +} \ No newline at end of file From ef795b0a5374c4b517c042e51c838fc5024dad43 Mon Sep 17 00:00:00 2001 From: Jimmy Bogard Date: Tue, 9 Jun 2026 16:42:14 -0500 Subject: [PATCH 2/4] Centralize license-key discovery and add shared LUCKYPENNY env var Move environment-variable fallback out of the MapperConfigurationExpression property getter and into LicenseAccessor, the single consumer, keeping the config expression a plain POCO. Add a second, shared LUCKYPENNY_LICENSE_KEY environment variable so the same Lucky Penny bundle key can be used across products (e.g. MediatR). Resolution precedence: explicit cfg.LicenseKey > AUTOMAPPER_LICENSE_KEY > LUCKYPENNY_LICENSE_KEY (product-specific wins, matching Duende's two-tier "most-specific config key wins" model). Tests now target LicenseAccessor.ResolveLicenseKey directly for precedence, and the class disables parallelization since it mutates process-global environment variables. Docs updated with the auto-discovery section. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/source/License-configuration.md | 15 ++ .../MapperConfigurationExpression.cs | 8 +- src/AutoMapper/Licensing/LicenseAccessor.cs | 15 +- .../LicenseKeyEnvironmentVariableTests.cs | 209 +++--------------- 4 files changed, 65 insertions(+), 182 deletions(-) diff --git a/docs/source/License-configuration.md b/docs/source/License-configuration.md index 097fc61663..eecb0971ca 100644 --- a/docs/source/License-configuration.md +++ b/docs/source/License-configuration.md @@ -18,6 +18,21 @@ var mapperConfiguration = new MapperConfiguration(cfg => { You can obtain a valid license from the [AutoMapper website](https://automapper.io). +### Auto-Discovery via Environment Variables + +If no license key is set in code, AutoMapper looks for one in environment variables. This is convenient for containerized and cloud environments, and for enterprises that share a single key across many services without code changes: + +- `AUTOMAPPER_LICENSE_KEY` – the AutoMapper-specific license key. +- `LUCKYPENNY_LICENSE_KEY` – a shared key usable across Lucky Penny products (for example, [MediatR](https://github.com/LuckyPennySoftware/MediatR) reads the same variable). Because it is shared, the key must be for a license that includes AutoMapper (a `Bundle` or AutoMapper edition); a MediatR-only license will not validate here. + +The license key is resolved in the following order of precedence, using the first value found: + +1. An explicit value set in code (`cfg.LicenseKey = "..."`). +2. The `AUTOMAPPER_LICENSE_KEY` environment variable. +3. The `LUCKYPENNY_LICENSE_KEY` environment variable. + +No code change is required when using an environment variable—just register AutoMapper as usual without setting `LicenseKey`. + ### License Enforcement Licensing is enforced via log messages at various levels: diff --git a/src/AutoMapper/Configuration/MapperConfigurationExpression.cs b/src/AutoMapper/Configuration/MapperConfigurationExpression.cs index c71ba333fb..6bfebeadfe 100644 --- a/src/AutoMapper/Configuration/MapperConfigurationExpression.cs +++ b/src/AutoMapper/Configuration/MapperConfigurationExpression.cs @@ -127,13 +127,7 @@ void IGlobalConfigurationExpression.Validator(Validator validator) => /// int IGlobalConfigurationExpression.RecursiveQueriesMaxDepth { get; set; } - private string _licenseKey; - - public string LicenseKey - { - get => _licenseKey ?? Environment.GetEnvironmentVariable("AUTOMAPPER_LICENSE_KEY"); - set => _licenseKey = value; - } + public string LicenseKey { get; set; } public ServiceLifetime ServiceLifetime { get; set; } = ServiceLifetime.Transient; diff --git a/src/AutoMapper/Licensing/LicenseAccessor.cs b/src/AutoMapper/Licensing/LicenseAccessor.cs index e98fc7be06..3292a2a491 100644 --- a/src/AutoMapper/Licensing/LicenseAccessor.cs +++ b/src/AutoMapper/Licensing/LicenseAccessor.cs @@ -9,6 +9,9 @@ namespace AutoMapper.Licensing; internal class LicenseAccessor { + internal const string AutoMapperLicenseKeyEnvVariable = "AUTOMAPPER_LICENSE_KEY"; + internal const string SharedLicenseKeyEnvVariable = "LUCKYPENNY_LICENSE_KEY"; + private readonly IGlobalConfiguration _configuration; private readonly ILogger _logger; @@ -32,7 +35,7 @@ private License Initialize() return _license; } - var key = _configuration.LicenseKey; + var key = ResolveLicenseKey(_configuration.LicenseKey); if (key == null) { return new License(); @@ -45,6 +48,16 @@ private License Initialize() } } + /// + /// Resolves the license key from, in order of precedence: the explicitly configured value, + /// the product-specific AUTOMAPPER_LICENSE_KEY environment variable, then the shared + /// LUCKYPENNY_LICENSE_KEY environment variable (usable across Lucky Penny products). + /// + internal static string ResolveLicenseKey(string explicitKey) => + explicitKey + ?? Environment.GetEnvironmentVariable(AutoMapperLicenseKeyEnvVariable) + ?? Environment.GetEnvironmentVariable(SharedLicenseKeyEnvVariable); + private Claim[] ValidateKey(string licenseKey) { try diff --git a/src/UnitTests/Licensing/LicenseKeyEnvironmentVariableTests.cs b/src/UnitTests/Licensing/LicenseKeyEnvironmentVariableTests.cs index 27f135cc09..4d1bbad3f3 100644 --- a/src/UnitTests/Licensing/LicenseKeyEnvironmentVariableTests.cs +++ b/src/UnitTests/Licensing/LicenseKeyEnvironmentVariableTests.cs @@ -1,208 +1,69 @@ -using Microsoft.Extensions.DependencyInjection; +using AutoMapper.Licensing; namespace AutoMapper.UnitTests.Licensing; +// Mutates process-global environment variables, so it must not run alongside +// other tests that read them. Disable parallelization for this class. +[Collection(nameof(LicenseKeyEnvironmentVariableTests))] +[CollectionDefinition(nameof(LicenseKeyEnvironmentVariableTests), DisableParallelization = true)] public class LicenseKeyEnvironmentVariableTests { - private const string EnvVarName = "AUTOMAPPER_LICENSE_KEY"; - - #region Environment Variable Auto-Detection Tests + private const string AutoMapperEnvVar = LicenseAccessor.AutoMapperLicenseKeyEnvVariable; + private const string SharedEnvVar = LicenseAccessor.SharedLicenseKeyEnvVariable; [Fact] - public void LicenseKey_ReadsFromEnvironmentVariable_WhenNotExplicitlySet() + public void ExplicitKey_TakesPrecedence_OverBothEnvironmentVariables() { - const string expectedKey = "test-license-key-12345"; - Environment.SetEnvironmentVariable(EnvVarName, expectedKey); - - try - { - var config = new MapperConfigurationExpression(); - - config.LicenseKey.ShouldBe(expectedKey); - } - finally - { - Environment.SetEnvironmentVariable(EnvVarName, null); - } - } - - [Fact] - public void LicenseKey_ReturnsNull_WhenEnvironmentVariableNotSet() - { - Environment.SetEnvironmentVariable(EnvVarName, null); - - var config = new MapperConfigurationExpression(); - - config.LicenseKey.ShouldBeNull(); - } - - #endregion - - #region Backward Compatibility - Old Way Tests - - [Fact] - public void LicenseKey_SupportsOldWay_DirectAssignment() - { - const string licenseKey = "old-way-explicit-key"; - var config = new MapperConfigurationExpression(); - - config.LicenseKey = licenseKey; - - config.LicenseKey.ShouldBe(licenseKey); + const string explicitKey = "explicit-license-key"; + WithEnvironment(autoMapper: "env-automapper-key", shared: "env-shared-key", () => + LicenseAccessor.ResolveLicenseKey(explicitKey).ShouldBe(explicitKey)); } [Fact] - public void LicenseKey_PrioritizesExplicitValue_OverEnvironmentVariable() + public void AutoMapperEnvironmentVariable_Used_WhenNoExplicitKey() { - const string envKey = "env-license-key"; - const string explicitKey = "explicit-license-key"; - Environment.SetEnvironmentVariable(EnvVarName, envKey); - - try - { - var config = new MapperConfigurationExpression(); - - config.LicenseKey = explicitKey; - - config.LicenseKey.ShouldBe(explicitKey); - config.LicenseKey.ShouldNotBe(envKey); - } - finally - { - Environment.SetEnvironmentVariable(EnvVarName, null); - } + const string autoMapperKey = "env-automapper-key"; + WithEnvironment(autoMapper: autoMapperKey, shared: null, () => + LicenseAccessor.ResolveLicenseKey(null).ShouldBe(autoMapperKey)); } [Fact] - public void LicenseKey_OldWayOverridesEnvironmentVariable_InConfigAction() + public void SharedEnvironmentVariable_Used_WhenOnlyItIsSet() { - const string envKey = "env-license-key"; - const string explicitKey = "explicit-override-key"; - Environment.SetEnvironmentVariable(EnvVarName, envKey); - - try - { - var config = new MapperConfigurationExpression(); - - config.LicenseKey = explicitKey; - - config.LicenseKey.ShouldBe(explicitKey); - } - finally - { - Environment.SetEnvironmentVariable(EnvVarName, null); - } + const string sharedKey = "env-shared-key"; + WithEnvironment(autoMapper: null, shared: sharedKey, () => + LicenseAccessor.ResolveLicenseKey(null).ShouldBe(sharedKey)); } - #endregion - - #region Integration Tests - Old Way with DI - [Fact] - public void AddAutoMapper_SupportsOldWay_ExplicitLicenseKey() + public void AutoMapperEnvironmentVariable_TakesPrecedence_OverSharedEnvironmentVariable() { - const string licenseKey = "old-way-integration-key"; - MapperConfigurationExpression capturedCfg = null; - - var services = new ServiceCollection(); - services.AddLogging(); - services.AddAutoMapper(cfg => - { - cfg.LicenseKey = licenseKey; - cfg.CreateMap(); - capturedCfg = (MapperConfigurationExpression)cfg; - }); - - var serviceProvider = services.BuildServiceProvider(); - var mapper = serviceProvider.GetRequiredService(); - - var result = mapper.Map(new TestSource { Name = "Test" }); - - result.ShouldNotBeNull(); - result.Name.ShouldBe("Test"); - capturedCfg.LicenseKey.ShouldBe(licenseKey); + const string autoMapperKey = "env-automapper-key"; + WithEnvironment(autoMapper: autoMapperKey, shared: "env-shared-key", () => + LicenseAccessor.ResolveLicenseKey(null).ShouldBe(autoMapperKey)); } [Fact] - public void AddAutoMapper_UsesEnvironmentVariable_WhenNoExplicitKeySet() + public void ReturnsNull_WhenNothingIsSet() { - const string licenseKey = "env-integration-key"; - Environment.SetEnvironmentVariable(EnvVarName, licenseKey); - MapperConfigurationExpression capturedCfg = null; - - try - { - var services = new ServiceCollection(); - services.AddLogging(); - services.AddAutoMapper(cfg => - { - cfg.CreateMap(); - capturedCfg = (MapperConfigurationExpression)cfg; - }); - - var serviceProvider = services.BuildServiceProvider(); - var mapper = serviceProvider.GetRequiredService(); - - var result = mapper.Map(new TestSource { Name = "Test" }); - - result.ShouldNotBeNull(); - result.Name.ShouldBe("Test"); - capturedCfg.LicenseKey.ShouldBe(licenseKey); - } - finally - { - Environment.SetEnvironmentVariable(EnvVarName, null); - } + WithEnvironment(autoMapper: null, shared: null, () => + LicenseAccessor.ResolveLicenseKey(null).ShouldBeNull()); } - [Fact] - public void AddAutoMapper_OldWayTakesPrecedence_OverEnvironmentVariable() + private static void WithEnvironment(string autoMapper, string shared, Action assert) { - const string envKey = "env-license-key"; - const string explicitKey = "explicit-license-key-from-old-way"; - Environment.SetEnvironmentVariable(EnvVarName, envKey); - MapperConfigurationExpression capturedCfg = null; - + var originalAutoMapper = Environment.GetEnvironmentVariable(AutoMapperEnvVar); + var originalShared = Environment.GetEnvironmentVariable(SharedEnvVar); try { - var services = new ServiceCollection(); - services.AddLogging(); - services.AddAutoMapper(cfg => - { - cfg.LicenseKey = explicitKey; - cfg.CreateMap(); - capturedCfg = (MapperConfigurationExpression)cfg; - }); - - var serviceProvider = services.BuildServiceProvider(); - var mapper = serviceProvider.GetRequiredService(); - - var result = mapper.Map(new TestSource { Name = "Test" }); - - result.ShouldNotBeNull(); - result.Name.ShouldBe("Test"); - capturedCfg.LicenseKey.ShouldBe(explicitKey); - capturedCfg.LicenseKey.ShouldNotBe(envKey); + Environment.SetEnvironmentVariable(AutoMapperEnvVar, autoMapper); + Environment.SetEnvironmentVariable(SharedEnvVar, shared); + assert(); } finally { - Environment.SetEnvironmentVariable(EnvVarName, null); + Environment.SetEnvironmentVariable(AutoMapperEnvVar, originalAutoMapper); + Environment.SetEnvironmentVariable(SharedEnvVar, originalShared); } } - - #endregion - - #region Test Helper Classes - - private class TestSource - { - public string Name { get; set; } - } - - private class TestDestination - { - public string Name { get; set; } - } - - #endregion -} \ No newline at end of file +} From 7d09c1ae9a0b0e5576300507555f5b63fffe4753 Mon Sep 17 00:00:00 2001 From: Jimmy Bogard Date: Wed, 10 Jun 2026 11:18:35 -0500 Subject: [PATCH 3/4] ci: don't fail the build when test-report publishing is denied dorny/test-reporter needs checks:write to create its report check-run, but GitHub forces GITHUB_TOKEN to read-only on pull_request runs from forks, so the step errored and failed the whole job even though all tests passed. Mark both report steps continue-on-error so report publishing is best-effort; the Build and Test step already reflects actual pass/fail. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 54061deef0..a1f30a63e2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,6 +39,10 @@ jobs: shell: pwsh - name: Report Test Results uses: dorny/test-reporter@v1 + # Publishing the report needs checks:write, which GitHub forces to read-only + # for pull_request runs from forks. Don't let that fail the build; the test + # step itself already reflects pass/fail. + continue-on-error: true if: success() || failure() with: name: Test Results (Linux) @@ -74,6 +78,9 @@ jobs: shell: pwsh - name: Report Test Results uses: dorny/test-reporter@v1 + # See note on the Linux job: report publishing is best-effort and must + # never fail the build (forked-PR tokens are read-only). + continue-on-error: true if: success() || failure() with: name: Test Results (Windows) From 53c8ff9d55352f5df693e9754bf3d919e5a5d33d Mon Sep 17 00:00:00 2001 From: Jimmy Bogard Date: Wed, 10 Jun 2026 11:25:34 -0500 Subject: [PATCH 4/4] ci: skip Azure login and signing on PRs (publish-only, secrets absent) The build-windows job authenticated to Azure via OIDC on every run, but the Azure secrets aren't exposed to pull requests (empty on forks), so the login step failed PR builds. Azure login and package signing only matter for the main-branch publish path, so gate both to github.ref == 'refs/heads/main', matching the existing Push to MyGet guard. Windows still builds and tests on PRs; it just skips the publish-only steps. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a1f30a63e2..c2c1b8e47d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -59,6 +59,9 @@ jobs: with: fetch-depth: 0 - name: Azure Login via OIDC + # Only needed to sign and publish packages on main. Skipped on PRs, + # where the Azure secrets aren't available (and are empty on forks). + if: github.ref == 'refs/heads/main' uses: azure/login@v2 with: client-id: ${{ secrets.AZURE_CLIENT_ID }} @@ -87,6 +90,8 @@ jobs: path: artifacts/**/*.trx reporter: dotnet-trx - name: Sign packages + # Signing uses the Azure Key Vault login above; only runs on main. + if: github.ref == 'refs/heads/main' run: |- foreach ($f in Get-ChildItem "./artifacts" -Filter "*.nupkg") { NuGetKeyVaultSignTool sign $f.FullName --file-digest sha256 --timestamp-rfc3161 http://timestamp.digicert.com --azure-key-vault-managed-identity --azure-key-vault-url ${{ secrets.AZURE_KEYVAULT_URI }} --azure-key-vault-certificate ${{ secrets.CODESIGN_CERT_NAME }}