diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 54061deef0..c2c1b8e47d 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) @@ -55,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 }} @@ -74,12 +81,17 @@ 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) 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 }} 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/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 new file mode 100644 index 0000000000..4d1bbad3f3 --- /dev/null +++ b/src/UnitTests/Licensing/LicenseKeyEnvironmentVariableTests.cs @@ -0,0 +1,69 @@ +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 AutoMapperEnvVar = LicenseAccessor.AutoMapperLicenseKeyEnvVariable; + private const string SharedEnvVar = LicenseAccessor.SharedLicenseKeyEnvVariable; + + [Fact] + public void ExplicitKey_TakesPrecedence_OverBothEnvironmentVariables() + { + const string explicitKey = "explicit-license-key"; + WithEnvironment(autoMapper: "env-automapper-key", shared: "env-shared-key", () => + LicenseAccessor.ResolveLicenseKey(explicitKey).ShouldBe(explicitKey)); + } + + [Fact] + public void AutoMapperEnvironmentVariable_Used_WhenNoExplicitKey() + { + const string autoMapperKey = "env-automapper-key"; + WithEnvironment(autoMapper: autoMapperKey, shared: null, () => + LicenseAccessor.ResolveLicenseKey(null).ShouldBe(autoMapperKey)); + } + + [Fact] + public void SharedEnvironmentVariable_Used_WhenOnlyItIsSet() + { + const string sharedKey = "env-shared-key"; + WithEnvironment(autoMapper: null, shared: sharedKey, () => + LicenseAccessor.ResolveLicenseKey(null).ShouldBe(sharedKey)); + } + + [Fact] + public void AutoMapperEnvironmentVariable_TakesPrecedence_OverSharedEnvironmentVariable() + { + const string autoMapperKey = "env-automapper-key"; + WithEnvironment(autoMapper: autoMapperKey, shared: "env-shared-key", () => + LicenseAccessor.ResolveLicenseKey(null).ShouldBe(autoMapperKey)); + } + + [Fact] + public void ReturnsNull_WhenNothingIsSet() + { + WithEnvironment(autoMapper: null, shared: null, () => + LicenseAccessor.ResolveLicenseKey(null).ShouldBeNull()); + } + + private static void WithEnvironment(string autoMapper, string shared, Action assert) + { + var originalAutoMapper = Environment.GetEnvironmentVariable(AutoMapperEnvVar); + var originalShared = Environment.GetEnvironmentVariable(SharedEnvVar); + try + { + Environment.SetEnvironmentVariable(AutoMapperEnvVar, autoMapper); + Environment.SetEnvironmentVariable(SharedEnvVar, shared); + assert(); + } + finally + { + Environment.SetEnvironmentVariable(AutoMapperEnvVar, originalAutoMapper); + Environment.SetEnvironmentVariable(SharedEnvVar, originalShared); + } + } +}