diff --git a/eng/MSBuild/Generators.props b/eng/MSBuild/Generators.props
index 2a99ec02f95..a5743854124 100644
--- a/eng/MSBuild/Generators.props
+++ b/eng/MSBuild/Generators.props
@@ -10,4 +10,8 @@
+
+
+
+
diff --git a/src/Generators/Microsoft.Gen.BuildMetadata/AnalyzerConfigOptionsExtensions.cs b/src/Generators/Microsoft.Gen.BuildMetadata/AnalyzerConfigOptionsExtensions.cs
new file mode 100644
index 00000000000..1492b341de6
--- /dev/null
+++ b/src/Generators/Microsoft.Gen.BuildMetadata/AnalyzerConfigOptionsExtensions.cs
@@ -0,0 +1,39 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Microsoft.CodeAnalysis.Diagnostics;
+
+namespace Microsoft.Gen.BuildMetadata;
+
+///
+/// Extension methods for to simplify MSBuild property access.
+///
+internal static class AnalyzerConfigOptionsExtensions
+{
+ private const string PropertyPrefix = "build_property.";
+
+ ///
+ /// Gets a boolean property value from MSBuild properties.
+ /// Supports "true"/"false" values (case-insensitive).
+ ///
+ /// The analyzer configuration options.
+ /// The property name (without "build_property." prefix).
+ /// True if the property value is "true", false otherwise.
+ public static bool GetBooleanProperty(this AnalyzerConfigOptions options, string propertyName)
+ {
+ var value = GetProperty(options, propertyName);
+ return string.Equals(value, "true", System.StringComparison.OrdinalIgnoreCase);
+ }
+
+ ///
+ /// Gets a string property value from MSBuild properties.
+ ///
+ /// The analyzer configuration options.
+ /// The property name (without "build_property." prefix).
+ /// The property value, or null if not found.
+ public static string? GetProperty(this AnalyzerConfigOptions options, string propertyName)
+ {
+ var key = string.Concat(PropertyPrefix, propertyName);
+ return options.TryGetValue(key, out var value) ? value : null;
+ }
+}
diff --git a/src/Generators/Microsoft.Gen.BuildMetadata/BuildMetadata.cs b/src/Generators/Microsoft.Gen.BuildMetadata/BuildMetadata.cs
new file mode 100644
index 00000000000..63943fae8ca
--- /dev/null
+++ b/src/Generators/Microsoft.Gen.BuildMetadata/BuildMetadata.cs
@@ -0,0 +1,6 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Microsoft.Gen.BuildMetadata;
+
+internal readonly record struct BuildMetadata(string? BuildId, string? BuildNumber, string? SourceBranchName, string? SourceVersion);
diff --git a/src/Generators/Microsoft.Gen.BuildMetadata/BuildMetadataGenerator.cs b/src/Generators/Microsoft.Gen.BuildMetadata/BuildMetadataGenerator.cs
new file mode 100644
index 00000000000..9c0656865ee
--- /dev/null
+++ b/src/Generators/Microsoft.Gen.BuildMetadata/BuildMetadataGenerator.cs
@@ -0,0 +1,57 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Text;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.Diagnostics;
+using Microsoft.CodeAnalysis.Text;
+
+namespace Microsoft.Gen.BuildMetadata;
+
+///
+/// Source generator that creates build metadata extensions from MSBuild properties.
+/// Supports both Azure DevOps and GitHub Actions build environments.
+///
+[Generator]
+public sealed class BuildMetadataGenerator : IIncrementalGenerator
+{
+ ///
+ public void Initialize(IncrementalGeneratorInitializationContext context)
+ {
+ var buildPropertiesPipeline = context.AnalyzerConfigOptionsProvider.Select((provider, ct) =>
+ {
+ return CreateBuildMetadata(provider.GlobalOptions);
+ });
+
+ context.RegisterSourceOutput(buildPropertiesPipeline, Execute);
+ }
+
+ private static BuildMetadata CreateBuildMetadata(AnalyzerConfigOptions globalOptions)
+ {
+ // Azure DevOps properties
+ var azureBuildId = globalOptions.GetProperty("BuildMetadataAzureBuildId");
+ var azureBuildNumber = globalOptions.GetProperty("BuildMetadataAzureBuildNumber");
+ var azureSourceBranchName = globalOptions.GetProperty("BuildMetadataAzureSourceBranchName");
+ var azureSourceVersion = globalOptions.GetProperty("BuildMetadataAzureSourceVersion");
+ var isAzureDevOps = globalOptions.GetBooleanProperty("BuildMetadataIsAzureDevOps");
+
+ // GitHub Actions properties
+ var gitHubRunId = globalOptions.GetProperty("BuildMetadataGitHubRunId");
+ var gitHubRunNumber = globalOptions.GetProperty("BuildMetadataGitHubRunNumber");
+ var gitHubRefName = globalOptions.GetProperty("BuildMetadataGitHubRefName");
+ var gitHubSha = globalOptions.GetProperty("BuildMetadataGitHubSha");
+
+ return new BuildMetadata(
+ isAzureDevOps ? azureBuildId : gitHubRunId,
+ isAzureDevOps ? azureBuildNumber : gitHubRunNumber,
+ isAzureDevOps ? azureSourceBranchName : gitHubRefName,
+ isAzureDevOps ? azureSourceVersion : gitHubSha);
+ }
+
+ private static void Execute(SourceProductionContext context, BuildMetadata buildMetadata)
+ {
+ var emitter = new Emitter(buildMetadata);
+ var result = emitter.Emit();
+ context.AddSource("BuildMetadataExtensions.g.cs", SourceText.From(result, Encoding.UTF8));
+ }
+}
diff --git a/src/Generators/Microsoft.Gen.BuildMetadata/Emitter.cs b/src/Generators/Microsoft.Gen.BuildMetadata/Emitter.cs
new file mode 100644
index 00000000000..c7248695027
--- /dev/null
+++ b/src/Generators/Microsoft.Gen.BuildMetadata/Emitter.cs
@@ -0,0 +1,184 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Diagnostics.CodeAnalysis;
+using Microsoft.Gen.Shared;
+
+namespace Microsoft.Gen.BuildMetadata;
+
+[SuppressMessage("Format", "S1199", Justification = "For better visualization of how the generated code will look like.")]
+internal sealed class Emitter : EmitterBase
+{
+ private const string DependencyInjectionNamespace = "global::Microsoft.Extensions.DependencyInjection.";
+ private const string ConfigurationNamespace = "global::Microsoft.Extensions.Configuration.";
+ private const string HostingNamespace = "global::Microsoft.Extensions.Hosting.";
+ private readonly string? _buildId;
+ private readonly string? _buildNumber;
+ private readonly string? _sourceBranchName;
+ private readonly string? _sourceVersion;
+
+ public Emitter(BuildMetadata buildMetadata)
+ {
+ _buildId = buildMetadata.BuildId;
+ _buildNumber = buildMetadata.BuildNumber;
+ _sourceBranchName = buildMetadata.SourceBranchName;
+ _sourceVersion = buildMetadata.SourceVersion;
+ }
+
+ public string Emit()
+ {
+ GenerateBuildMetadataExtensions();
+ return Capture();
+ }
+
+ private void GenerateBuildMetadataSource()
+ {
+ OutGeneratedCodeAttribute();
+ OutLn("[global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)]");
+ OutLn($"private sealed class BuildMetadataSource : {ConfigurationNamespace}IConfigurationSource");
+ OutOpenBrace();
+ {
+ OutLn("public string SectionName { get; }");
+ OutLn();
+
+ OutLn("public BuildMetadataSource(string sectionName)");
+ OutOpenBrace();
+ {
+ OutNullGuards(checkBuilder: false);
+ OutLn("SectionName = sectionName;");
+ }
+
+ OutCloseBrace();
+ OutLn();
+ OutLn($"public {ConfigurationNamespace}IConfigurationProvider Build({ConfigurationNamespace}IConfigurationBuilder builder)");
+ OutOpenBrace();
+ {
+ OutLn($"return new {ConfigurationNamespace}Memory.MemoryConfigurationProvider(new {ConfigurationNamespace}Memory.MemoryConfigurationSource())");
+ OutOpenBrace();
+ {
+ OutLn($$"""{ $"{SectionName}:buildid", "{{_buildId}}" },""");
+ OutLn($$"""{ $"{SectionName}:buildnumber", "{{_buildNumber}}" },""");
+ OutLn($$"""{ $"{SectionName}:sourcebranchname", "{{_sourceBranchName}}" },""");
+ OutLn($$"""{ $"{SectionName}:sourceversion", "{{_sourceVersion}}" },""");
+ }
+
+ OutCloseBraceWithExtra(";");
+ }
+
+ OutCloseBrace();
+ }
+
+ OutCloseBrace();
+ }
+
+ private void GenerateBuildMetadataExtensions()
+ {
+ OutLn("namespace Microsoft.Extensions.AmbientMetadata");
+ OutOpenBrace();
+ {
+ OutGeneratedCodeAttribute();
+ OutLn("internal static class BuildMetadataGeneratedExtensions");
+ OutOpenBrace();
+ {
+ OutLn("private const string DefaultSectionName = \"ambientmetadata:build\";");
+ OutLn();
+
+ GenerateBuildMetadataSource();
+ OutLn();
+
+ OutLn($"public static {HostingNamespace}IHostBuilder UseBuildMetadata(this {HostingNamespace}IHostBuilder builder, string sectionName = DefaultSectionName)");
+ OutOpenBrace();
+ {
+ OutNullGuards();
+ OutLn("_ = builder.ConfigureHostConfiguration(configBuilder => configBuilder.AddBuildMetadata(sectionName))");
+ Indent();
+ OutLn(".ConfigureServices((hostBuilderContext, serviceCollection) =>");
+ Indent();
+ OutLn($"{DependencyInjectionNamespace}BuildMetadataServiceCollectionExtensions.AddBuildMetadata(serviceCollection, hostBuilderContext.Configuration.GetSection(sectionName)));");
+ Unindent();
+ Unindent();
+ OutLn();
+
+ OutLn("return builder;");
+ }
+
+ OutCloseBrace();
+ OutLn();
+
+ OutLn("public static TBuilder UseBuildMetadata(this TBuilder builder, string sectionName = DefaultSectionName)");
+ Indent();
+ OutLn($"where TBuilder : {HostingNamespace}IHostApplicationBuilder");
+ Unindent();
+ OutOpenBrace();
+ {
+ OutNullGuards();
+ OutLn("_ = builder.Configuration.AddBuildMetadata(sectionName);");
+ OutLn($"{DependencyInjectionNamespace}BuildMetadataServiceCollectionExtensions.AddBuildMetadata(builder.Services, builder.Configuration.GetSection(sectionName));");
+ OutLn();
+
+ OutLn("return builder;");
+ }
+
+ OutCloseBrace();
+ OutLn();
+
+#pragma warning disable S103 // Lines should not be too long
+ OutLn($"public static {ConfigurationNamespace}IConfigurationBuilder AddBuildMetadata(this {ConfigurationNamespace}IConfigurationBuilder builder, string sectionName = DefaultSectionName)");
+#pragma warning restore S103 // Lines should not be too long
+ OutOpenBrace();
+ {
+ OutNullGuards();
+ OutLn("return builder.Add(new BuildMetadataSource(sectionName));");
+ }
+
+ OutCloseBrace();
+ }
+
+ OutCloseBrace();
+ }
+
+ OutCloseBrace();
+ }
+
+ private void OutNullGuards(bool checkBuilder = true)
+ {
+ OutPP("#if !NET");
+
+ if (checkBuilder)
+ {
+ OutLn("if (builder is null)");
+ OutOpenBrace();
+ OutLn("throw new global::System.ArgumentNullException(nameof(builder));");
+ OutCloseBrace();
+ OutLn();
+ }
+
+ OutLn("if (string.IsNullOrWhiteSpace(sectionName))");
+ OutOpenBrace();
+ {
+ OutLn("if (sectionName is null)");
+ OutOpenBrace();
+ {
+ OutLn("throw new global::System.ArgumentNullException(nameof(sectionName));");
+ }
+
+ OutCloseBrace();
+ OutLn();
+ OutLn("throw new global::System.ArgumentException(\"The value cannot be an empty string or composed entirely of whitespace.\", nameof(sectionName));");
+ }
+
+ OutCloseBrace();
+
+ OutPP("#else");
+
+ if (checkBuilder)
+ {
+ OutLn("global::System.ArgumentNullException.ThrowIfNull(builder);");
+ }
+
+ OutLn("global::System.ArgumentException.ThrowIfNullOrWhiteSpace(sectionName);");
+
+ OutPP("#endif");
+ OutLn();
+ }
+}
diff --git a/src/Generators/Microsoft.Gen.BuildMetadata/Microsoft.Gen.BuildMetadata.csproj b/src/Generators/Microsoft.Gen.BuildMetadata/Microsoft.Gen.BuildMetadata.csproj
new file mode 100644
index 00000000000..48e685b11d3
--- /dev/null
+++ b/src/Generators/Microsoft.Gen.BuildMetadata/Microsoft.Gen.BuildMetadata.csproj
@@ -0,0 +1,37 @@
+
+
+ Microsoft.Gen.BuildMetadata
+ Code generator to support Microsoft.Extensions.AmbientMetadata.Build
+ Fundamentals
+
+
+
+ cs
+ true
+ true
+ true
+ true
+
+ $(NoWarn);EA0002;CA1852
+
+
+
+ normal
+ 100
+ 75
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Libraries/Microsoft.Extensions.AmbientMetadata.Build/BuildMetadata.cs b/src/Libraries/Microsoft.Extensions.AmbientMetadata.Build/BuildMetadata.cs
new file mode 100644
index 00000000000..623a5fc7a96
--- /dev/null
+++ b/src/Libraries/Microsoft.Extensions.AmbientMetadata.Build/BuildMetadata.cs
@@ -0,0 +1,39 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Microsoft.Extensions.AmbientMetadata;
+
+///
+/// Data model for the Build metadata.
+///
+///
+/// The values are automatically grabbed from environment variables at build time in CI pipeline and saved in generated code.
+/// At startup time, the class properties will be initialized from the generated code.
+/// Currently supported CI pipelines:
+///
+/// - Azure DevOps
+/// - GitHub Actions
+///
+///
+public class BuildMetadata
+{
+ ///
+ /// Gets or sets the ID of the record for the build, also known as the run ID.
+ ///
+ public string? BuildId { get; set; }
+
+ ///
+ /// Gets or sets the name of the completed build, also known as the run number.
+ ///
+ public string? BuildNumber { get; set; }
+
+ ///
+ /// Gets or sets the name of the branch in the triggering repo the build was queued for, also known as the ref name.
+ ///
+ public string? SourceBranchName { get; set; }
+
+ ///
+ /// Gets or sets the latest version control change that is included in this build, also known as the commit SHA.
+ ///
+ public string? SourceVersion { get; set; }
+}
diff --git a/src/Libraries/Microsoft.Extensions.AmbientMetadata.Build/BuildMetadataServiceCollectionExtensions.cs b/src/Libraries/Microsoft.Extensions.AmbientMetadata.Build/BuildMetadataServiceCollectionExtensions.cs
new file mode 100644
index 00000000000..55fa8a2c47d
--- /dev/null
+++ b/src/Libraries/Microsoft.Extensions.AmbientMetadata.Build/BuildMetadataServiceCollectionExtensions.cs
@@ -0,0 +1,53 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System;
+using Microsoft.Extensions.AmbientMetadata;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Shared.Diagnostics;
+
+namespace Microsoft.Extensions.DependencyInjection;
+
+///
+/// Extensions for Build metadata.
+///
+public static class BuildMetadataServiceCollectionExtensions
+{
+ ///
+ /// Adds an instance of to the .
+ ///
+ /// The to add the services to.
+ /// The configuration section to bind the instance of against.
+ /// The for call chaining.
+ /// The argument or is .
+ public static IServiceCollection AddBuildMetadata(this IServiceCollection services, IConfigurationSection section)
+ {
+ _ = Throw.IfNull(services);
+ _ = Throw.IfNull(section);
+
+ _ = services
+ .AddOptions()
+ .Bind(section);
+
+ return services;
+ }
+
+ ///
+ /// Adds an instance of to the .
+ ///
+ /// The to add the services to.
+ /// The delegate to configure with.
+ /// The for call chaining.
+ /// The argument or is .
+ public static IServiceCollection AddBuildMetadata(this IServiceCollection services, Action configure)
+ {
+ _ = Throw.IfNull(services);
+ _ = Throw.IfNull(configure);
+
+ _ = services
+ .AddOptions()
+ .Configure(configure);
+
+ return services;
+ }
+}
diff --git a/src/Libraries/Microsoft.Extensions.AmbientMetadata.Build/Microsoft.Extensions.AmbientMetadata.Build.csproj b/src/Libraries/Microsoft.Extensions.AmbientMetadata.Build/Microsoft.Extensions.AmbientMetadata.Build.csproj
new file mode 100644
index 00000000000..3fe3acddef4
--- /dev/null
+++ b/src/Libraries/Microsoft.Extensions.AmbientMetadata.Build/Microsoft.Extensions.AmbientMetadata.Build.csproj
@@ -0,0 +1,43 @@
+
+
+
+ Microsoft.Extensions.AmbientMetadata
+ Runtime information provider for Build ambient metadata.
+ Fundamentals
+ $(NetCoreTargetFrameworks)
+
+
+
+ true
+ true
+ true
+ true
+
+
+
+ dev
+ EXTEXP0009
+ 100
+ 100
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Libraries/Microsoft.Extensions.AmbientMetadata.Build/README.md b/src/Libraries/Microsoft.Extensions.AmbientMetadata.Build/README.md
new file mode 100644
index 00000000000..5fc308b7b9c
--- /dev/null
+++ b/src/Libraries/Microsoft.Extensions.AmbientMetadata.Build/README.md
@@ -0,0 +1,4 @@
+# About This Project
+
+This ambient metadata provider provides Build information at runtime.
+See https://learn.microsoft.com/azure/devops/pipelines/build/variables for details.
diff --git a/src/Libraries/Microsoft.Extensions.AmbientMetadata.Build/buildTransitive/Microsoft.Extensions.AmbientMetadata.Build.props b/src/Libraries/Microsoft.Extensions.AmbientMetadata.Build/buildTransitive/Microsoft.Extensions.AmbientMetadata.Build.props
new file mode 100644
index 00000000000..6ff6c318c33
--- /dev/null
+++ b/src/Libraries/Microsoft.Extensions.AmbientMetadata.Build/buildTransitive/Microsoft.Extensions.AmbientMetadata.Build.props
@@ -0,0 +1,28 @@
+
+
+
+ $(Build_BuildId)
+ $(Build_BuildNumber)
+ $(Build_SourceBranchName)
+ $(Build_SourceVersion)
+ $(TF_BUILD)
+
+
+ $(GITHUB_RUN_ID)
+ $(GITHUB_RUN_NUMBER)
+ $(GITHUB_REF_NAME)
+ $(GITHUB_SHA)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/test/Generators/Microsoft.Gen.BuildMetadata/Generated/BuildMetadataConfigBuilderExtensionsTests.cs b/test/Generators/Microsoft.Gen.BuildMetadata/Generated/BuildMetadataConfigBuilderExtensionsTests.cs
new file mode 100644
index 00000000000..e09351c5ee7
--- /dev/null
+++ b/test/Generators/Microsoft.Gen.BuildMetadata/Generated/BuildMetadataConfigBuilderExtensionsTests.cs
@@ -0,0 +1,45 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System;
+using Microsoft.Extensions.AmbientMetadata;
+using Microsoft.Extensions.Configuration;
+using Xunit;
+
+namespace Microsoft.Gen.BuildMetadata.Test;
+
+public class BuildMetadataConfigBuilderExtensionsTests
+{
+ [Fact]
+ public void GivenNullBuilder_ThrowsArgumentNullException()
+ {
+ // Arrange
+ IConfigurationBuilder? builder = null;
+
+ // Act and Assert
+ Assert.Throws(() => builder!.AddBuildMetadata());
+ }
+
+ [Fact]
+ public void GivenNullSectionName_ThrowsArgumentNullException()
+ {
+ // Arrange
+ ConfigurationBuilder builder = new();
+
+ // Act and Assert
+ Assert.Throws(() => builder.AddBuildMetadata(sectionName: null!));
+ }
+
+ [Theory]
+ [InlineData("")]
+ [InlineData(" ")]
+ public void GivenWhitespaceSectionName_ThrowsArgumentException(string sectionName)
+ {
+ // Arrange
+ ConfigurationBuilder builder = new();
+
+ // Act and Assert
+ Assert.Throws(() => builder.AddBuildMetadata(sectionName: sectionName));
+ }
+
+}
diff --git a/test/Generators/Microsoft.Gen.BuildMetadata/Generated/BuildMetadataHostBuilderExtensionsTests.cs b/test/Generators/Microsoft.Gen.BuildMetadata/Generated/BuildMetadataHostBuilderExtensionsTests.cs
new file mode 100644
index 00000000000..2c2cbd9b631
--- /dev/null
+++ b/test/Generators/Microsoft.Gen.BuildMetadata/Generated/BuildMetadataHostBuilderExtensionsTests.cs
@@ -0,0 +1,47 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using FluentAssertions;
+using Microsoft.Extensions.AmbientMetadata;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Hosting.Testing;
+using Microsoft.Extensions.Options;
+using Xunit;
+
+namespace Microsoft.Gen.BuildMetadata.Test;
+
+public class BuildMetadataHostBuilderExtensionsTests
+{
+ [Fact]
+ public void Verify_HostBuilder()
+ {
+ var host = FakeHost.CreateBuilder()
+ .UseBuildMetadata()
+ .Build();
+
+ var buildMetadata = host.Services.GetRequiredService>();
+
+ buildMetadata.Value.Should().NotBeNull();
+ buildMetadata.Value.BuildId.Should().Be("GeneratedTest_AzureBuildId");
+ buildMetadata.Value.BuildNumber.Should().Be("GeneratedTest_AzureBuildNumber");
+ buildMetadata.Value.SourceBranchName.Should().Be("GeneratedTest_AzureSourceBranchName");
+ buildMetadata.Value.SourceVersion.Should().Be("GeneratedTest_AzureSourceVersion");
+ }
+
+ [Fact]
+ public void Verify_ApplicationHostBuilder()
+ {
+ var host = Host.CreateApplicationBuilder()
+ .UseBuildMetadata()
+ .Build();
+
+ var buildMetadata = host.Services.GetRequiredService>();
+
+ buildMetadata.Value.Should().NotBeNull();
+ buildMetadata.Value.BuildId.Should().Be("GeneratedTest_AzureBuildId");
+ buildMetadata.Value.BuildNumber.Should().Be("GeneratedTest_AzureBuildNumber");
+ buildMetadata.Value.SourceBranchName.Should().Be("GeneratedTest_AzureSourceBranchName");
+ buildMetadata.Value.SourceVersion.Should().Be("GeneratedTest_AzureSourceVersion");
+ }
+}
diff --git a/test/Generators/Microsoft.Gen.BuildMetadata/Generated/Microsoft.Gen.BuildMetadata.Generated.Tests.csproj b/test/Generators/Microsoft.Gen.BuildMetadata/Generated/Microsoft.Gen.BuildMetadata.Generated.Tests.csproj
new file mode 100644
index 00000000000..0792e9a59e6
--- /dev/null
+++ b/test/Generators/Microsoft.Gen.BuildMetadata/Generated/Microsoft.Gen.BuildMetadata.Generated.Tests.csproj
@@ -0,0 +1,30 @@
+
+
+ Microsoft.Gen.BuildMetadata.Test
+ Tests for Microsoft.Gen.BuildMetadata.
+
+
+
+ $(TestNetCoreTargetFrameworks)
+ true
+ true
+ $(NoWarn);IDE0161;S1144
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/test/Generators/Microsoft.Gen.BuildMetadata/Generated/Microsoft.Gen.BuildMetadata.Generated.Tests.props b/test/Generators/Microsoft.Gen.BuildMetadata/Generated/Microsoft.Gen.BuildMetadata.Generated.Tests.props
new file mode 100644
index 00000000000..e1d31ac5d8b
--- /dev/null
+++ b/test/Generators/Microsoft.Gen.BuildMetadata/Generated/Microsoft.Gen.BuildMetadata.Generated.Tests.props
@@ -0,0 +1,26 @@
+
+
+ GeneratedTest_AzureBuildId
+ GeneratedTest_AzureBuildNumber
+ GeneratedTest_AzureSourceBranchName
+ GeneratedTest_AzureSourceVersion
+ true
+
+ GeneratedTest_GitHubRunId
+ GeneratedTest_GitHubRunNumber
+ GeneratedTest_GitHubRefName
+ GeneratedTest_GitHubSha
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/test/Generators/Microsoft.Gen.BuildMetadata/Unit/GeneratorTests.cs b/test/Generators/Microsoft.Gen.BuildMetadata/Unit/GeneratorTests.cs
new file mode 100644
index 00000000000..b71edd5d02b
--- /dev/null
+++ b/test/Generators/Microsoft.Gen.BuildMetadata/Unit/GeneratorTests.cs
@@ -0,0 +1,134 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System;
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using System.Linq;
+using System.Reflection;
+using System.Threading.Tasks;
+using FluentAssertions;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.Diagnostics;
+using Microsoft.Gen.Shared;
+using VerifyTests;
+using VerifyXunit;
+using Xunit;
+
+namespace Microsoft.Gen.BuildMetadata.Test;
+
+[Collection("BuildMetadataEmitterTests")]
+public class GeneratorTests
+{
+ private readonly VerifySettings _verifySettings;
+
+ public GeneratorTests()
+ {
+ _verifySettings = new VerifySettings();
+ _verifySettings.UseDirectory("Verified");
+
+ _verifySettings.ScrubLinesWithReplace(value =>
+ {
+ if (value.Contains(GeneratorUtilities.GeneratedCodeAttribute))
+ {
+ return value.Replace(GeneratorUtilities.CurrentVersion, "VERSION");
+ }
+
+ return value;
+ });
+ }
+
+ [Theory]
+ [CombinatorialData]
+ public async Task BuildMetadataGenerator_ShouldGenerate([CombinatorialValues(true, false, null)] bool? isAzureDevOps)
+ {
+ var source = string.Empty; // Empty source, no attributes
+
+ // Create test options based on the isAzureDevOps parameter
+ var optionsProvider = CreateTestOptionsProvider(isAzureDevOps);
+
+ var (d, sources) = await RunGenerator(source, optionsProvider);
+
+ d.Should().BeEmpty();
+ sources.Should().HaveCount(1);
+
+ var settings = new VerifySettings(_verifySettings);
+ settings.DisableRequireUniquePrefix();
+ settings.UseParameters(isAzureDevOps);
+
+ await Verifier.Verify(sources.Select(s => s.SourceText.ToString()), settings);
+ }
+
+ private static TestAnalyzerConfigOptionsProvider CreateTestOptionsProvider(bool? isAzureDevOps)
+ {
+ return new TestAnalyzerConfigOptionsProvider(new Dictionary
+ {
+ { "build_property.BuildMetadataAzureBuildId", "TEST_AZURE_BUILDID" },
+ { "build_property.BuildMetadataAzureBuildNumber", "TEST_AZURE_BUILDNUMBER" },
+ { "build_property.BuildMetadataAzureSourceBranchName", "TEST_AZURE_SOURCEBRANCHNAME" },
+ { "build_property.BuildMetadataAzureSourceVersion", "TEST_AZURE_SOURCEVERSION" },
+ { "build_property.BuildMetadataIsAzureDevOps", isAzureDevOps?.ToString() ?? "false" },
+ { "build_property.BuildMetadataGitHubRunId", "TEST_GITHUB_RUNID" },
+ { "build_property.BuildMetadataGitHubRunNumber", "TEST_GITHUB_RUNNUMBER" },
+ { "build_property.BuildMetadataGitHubRefName", "TEST_GITHUB_REFNAME" },
+ { "build_property.BuildMetadataGitHubSha", "TEST_GITHUB_SHA" }
+ });
+ }
+
+ private static async Task<(IReadOnlyList diagnostics, IReadOnlyList sources)> RunGenerator(
+ string source,
+ TestAnalyzerConfigOptionsProvider optionsProvider)
+ {
+ // Create a test project and compilation
+ var proj = RoslynTestUtils.CreateTestProject(Array.Empty());
+ proj = proj.WithDocument("source.cs", source);
+ proj.CommitChanges();
+
+ var comp = await proj.GetCompilationAsync();
+
+ // Create the generator driver with the options provider
+ var driver = Microsoft.CodeAnalysis.CSharp.CSharpGeneratorDriver.Create(
+ generators: new[] { new BuildMetadataGenerator().AsSourceGenerator() },
+ parseOptions: CSharpParseOptions.Default.WithLanguageVersion(LanguageVersion.Preview),
+ optionsProvider: optionsProvider);
+
+ var result = driver.RunGeneratorsAndUpdateCompilation(comp!, out var outputCompilation, out var diagnostics);
+ var runResult = result.GetRunResult();
+
+ return (diagnostics, runResult.Results[0].GeneratedSources);
+ }
+
+ private sealed class TestAnalyzerConfigOptionsProvider : AnalyzerConfigOptionsProvider
+ {
+ private readonly TestAnalyzerConfigOptions _globalOptions;
+
+ public TestAnalyzerConfigOptionsProvider(Dictionary globalOptions)
+ {
+ _globalOptions = new TestAnalyzerConfigOptions(globalOptions);
+ }
+
+ public override AnalyzerConfigOptions GlobalOptions => _globalOptions;
+
+ public override AnalyzerConfigOptions GetOptions(SyntaxTree tree) => _globalOptions;
+
+ public override AnalyzerConfigOptions GetOptions(AdditionalText textFile) => _globalOptions;
+ }
+
+ private sealed class TestAnalyzerConfigOptions : AnalyzerConfigOptions
+ {
+ private readonly Dictionary _options;
+
+ public TestAnalyzerConfigOptions(Dictionary options)
+ {
+ _options = options;
+ }
+
+ public override bool TryGetValue(string key, [NotNullWhen(true)] out string? value)
+ {
+ return _options.TryGetValue(key, out value);
+ }
+
+ public override IEnumerable Keys => _options.Keys;
+ }
+}
diff --git a/test/Generators/Microsoft.Gen.BuildMetadata/Unit/Microsoft.Gen.BuildMetadata.Unit.Tests.csproj b/test/Generators/Microsoft.Gen.BuildMetadata/Unit/Microsoft.Gen.BuildMetadata.Unit.Tests.csproj
new file mode 100644
index 00000000000..f34950cbb88
--- /dev/null
+++ b/test/Generators/Microsoft.Gen.BuildMetadata/Unit/Microsoft.Gen.BuildMetadata.Unit.Tests.csproj
@@ -0,0 +1,23 @@
+
+
+ Microsoft.Gen.BuildMetadata.Test
+ Tests for Microsoft.Gen.BuildMetadata
+
+
+
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/test/Generators/Microsoft.Gen.BuildMetadata/Unit/Verified/GeneratorTests.BuildMetadataGenerator_ShouldGenerate_isAzureDevOps=False.verified.txt b/test/Generators/Microsoft.Gen.BuildMetadata/Unit/Verified/GeneratorTests.BuildMetadataGenerator_ShouldGenerate_isAzureDevOps=False.verified.txt
new file mode 100644
index 00000000000..bf0810d20af
--- /dev/null
+++ b/test/Generators/Microsoft.Gen.BuildMetadata/Unit/Verified/GeneratorTests.BuildMetadataGenerator_ShouldGenerate_isAzureDevOps=False.verified.txt
@@ -0,0 +1,134 @@
+[
+//
+#nullable enable
+#pragma warning disable CS1591 // Compensate for https://github.com/dotnet/roslyn/issues/54103
+namespace Microsoft.Extensions.AmbientMetadata
+{
+ [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Gen.BuildMetadata", "VERSION")]
+ internal static class BuildMetadataGeneratedExtensions
+ {
+ private const string DefaultSectionName = "ambientmetadata:build";
+
+ [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Gen.BuildMetadata", "VERSION")]
+ [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)]
+ private sealed class BuildMetadataSource : global::Microsoft.Extensions.Configuration.IConfigurationSource
+ {
+ public string SectionName { get; }
+
+ public BuildMetadataSource(string sectionName)
+ {
+#if !NET
+ if (string.IsNullOrWhiteSpace(sectionName))
+ {
+ if (sectionName is null)
+ {
+ throw new global::System.ArgumentNullException(nameof(sectionName));
+ }
+
+ throw new global::System.ArgumentException("The value cannot be an empty string or composed entirely of whitespace.", nameof(sectionName));
+ }
+#else
+ global::System.ArgumentException.ThrowIfNullOrWhiteSpace(sectionName);
+#endif
+
+ SectionName = sectionName;
+ }
+
+ public global::Microsoft.Extensions.Configuration.IConfigurationProvider Build(global::Microsoft.Extensions.Configuration.IConfigurationBuilder builder)
+ {
+ return new global::Microsoft.Extensions.Configuration.Memory.MemoryConfigurationProvider(new global::Microsoft.Extensions.Configuration.Memory.MemoryConfigurationSource())
+ {
+ { $"{SectionName}:buildid", "TEST_GITHUB_RUNID" },
+ { $"{SectionName}:buildnumber", "TEST_GITHUB_RUNNUMBER" },
+ { $"{SectionName}:sourcebranchname", "TEST_GITHUB_REFNAME" },
+ { $"{SectionName}:sourceversion", "TEST_GITHUB_SHA" },
+ };
+ }
+ }
+
+ public static global::Microsoft.Extensions.Hosting.IHostBuilder UseBuildMetadata(this global::Microsoft.Extensions.Hosting.IHostBuilder builder, string sectionName = DefaultSectionName)
+ {
+#if !NET
+ if (builder is null)
+ {
+ throw new global::System.ArgumentNullException(nameof(builder));
+ }
+
+ if (string.IsNullOrWhiteSpace(sectionName))
+ {
+ if (sectionName is null)
+ {
+ throw new global::System.ArgumentNullException(nameof(sectionName));
+ }
+
+ throw new global::System.ArgumentException("The value cannot be an empty string or composed entirely of whitespace.", nameof(sectionName));
+ }
+#else
+ global::System.ArgumentNullException.ThrowIfNull(builder);
+ global::System.ArgumentException.ThrowIfNullOrWhiteSpace(sectionName);
+#endif
+
+ _ = builder.ConfigureHostConfiguration(configBuilder => configBuilder.AddBuildMetadata(sectionName))
+ .ConfigureServices((hostBuilderContext, serviceCollection) =>
+ global::Microsoft.Extensions.DependencyInjection.BuildMetadataServiceCollectionExtensions.AddBuildMetadata(serviceCollection, hostBuilderContext.Configuration.GetSection(sectionName)));
+
+ return builder;
+ }
+
+ public static TBuilder UseBuildMetadata(this TBuilder builder, string sectionName = DefaultSectionName)
+ where TBuilder : global::Microsoft.Extensions.Hosting.IHostApplicationBuilder
+ {
+#if !NET
+ if (builder is null)
+ {
+ throw new global::System.ArgumentNullException(nameof(builder));
+ }
+
+ if (string.IsNullOrWhiteSpace(sectionName))
+ {
+ if (sectionName is null)
+ {
+ throw new global::System.ArgumentNullException(nameof(sectionName));
+ }
+
+ throw new global::System.ArgumentException("The value cannot be an empty string or composed entirely of whitespace.", nameof(sectionName));
+ }
+#else
+ global::System.ArgumentNullException.ThrowIfNull(builder);
+ global::System.ArgumentException.ThrowIfNullOrWhiteSpace(sectionName);
+#endif
+
+ _ = builder.Configuration.AddBuildMetadata(sectionName);
+ global::Microsoft.Extensions.DependencyInjection.BuildMetadataServiceCollectionExtensions.AddBuildMetadata(builder.Services, builder.Configuration.GetSection(sectionName));
+
+ return builder;
+ }
+
+ public static global::Microsoft.Extensions.Configuration.IConfigurationBuilder AddBuildMetadata(this global::Microsoft.Extensions.Configuration.IConfigurationBuilder builder, string sectionName = DefaultSectionName)
+ {
+#if !NET
+ if (builder is null)
+ {
+ throw new global::System.ArgumentNullException(nameof(builder));
+ }
+
+ if (string.IsNullOrWhiteSpace(sectionName))
+ {
+ if (sectionName is null)
+ {
+ throw new global::System.ArgumentNullException(nameof(sectionName));
+ }
+
+ throw new global::System.ArgumentException("The value cannot be an empty string or composed entirely of whitespace.", nameof(sectionName));
+ }
+#else
+ global::System.ArgumentNullException.ThrowIfNull(builder);
+ global::System.ArgumentException.ThrowIfNullOrWhiteSpace(sectionName);
+#endif
+
+ return builder.Add(new BuildMetadataSource(sectionName));
+ }
+ }
+}
+
+]
diff --git a/test/Generators/Microsoft.Gen.BuildMetadata/Unit/Verified/GeneratorTests.BuildMetadataGenerator_ShouldGenerate_isAzureDevOps=True.verified.txt b/test/Generators/Microsoft.Gen.BuildMetadata/Unit/Verified/GeneratorTests.BuildMetadataGenerator_ShouldGenerate_isAzureDevOps=True.verified.txt
new file mode 100644
index 00000000000..42f2fbc14d6
--- /dev/null
+++ b/test/Generators/Microsoft.Gen.BuildMetadata/Unit/Verified/GeneratorTests.BuildMetadataGenerator_ShouldGenerate_isAzureDevOps=True.verified.txt
@@ -0,0 +1,134 @@
+[
+//
+#nullable enable
+#pragma warning disable CS1591 // Compensate for https://github.com/dotnet/roslyn/issues/54103
+namespace Microsoft.Extensions.AmbientMetadata
+{
+ [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Gen.BuildMetadata", "VERSION")]
+ internal static class BuildMetadataGeneratedExtensions
+ {
+ private const string DefaultSectionName = "ambientmetadata:build";
+
+ [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Gen.BuildMetadata", "VERSION")]
+ [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)]
+ private sealed class BuildMetadataSource : global::Microsoft.Extensions.Configuration.IConfigurationSource
+ {
+ public string SectionName { get; }
+
+ public BuildMetadataSource(string sectionName)
+ {
+#if !NET
+ if (string.IsNullOrWhiteSpace(sectionName))
+ {
+ if (sectionName is null)
+ {
+ throw new global::System.ArgumentNullException(nameof(sectionName));
+ }
+
+ throw new global::System.ArgumentException("The value cannot be an empty string or composed entirely of whitespace.", nameof(sectionName));
+ }
+#else
+ global::System.ArgumentException.ThrowIfNullOrWhiteSpace(sectionName);
+#endif
+
+ SectionName = sectionName;
+ }
+
+ public global::Microsoft.Extensions.Configuration.IConfigurationProvider Build(global::Microsoft.Extensions.Configuration.IConfigurationBuilder builder)
+ {
+ return new global::Microsoft.Extensions.Configuration.Memory.MemoryConfigurationProvider(new global::Microsoft.Extensions.Configuration.Memory.MemoryConfigurationSource())
+ {
+ { $"{SectionName}:buildid", "TEST_AZURE_BUILDID" },
+ { $"{SectionName}:buildnumber", "TEST_AZURE_BUILDNUMBER" },
+ { $"{SectionName}:sourcebranchname", "TEST_AZURE_SOURCEBRANCHNAME" },
+ { $"{SectionName}:sourceversion", "TEST_AZURE_SOURCEVERSION" },
+ };
+ }
+ }
+
+ public static global::Microsoft.Extensions.Hosting.IHostBuilder UseBuildMetadata(this global::Microsoft.Extensions.Hosting.IHostBuilder builder, string sectionName = DefaultSectionName)
+ {
+#if !NET
+ if (builder is null)
+ {
+ throw new global::System.ArgumentNullException(nameof(builder));
+ }
+
+ if (string.IsNullOrWhiteSpace(sectionName))
+ {
+ if (sectionName is null)
+ {
+ throw new global::System.ArgumentNullException(nameof(sectionName));
+ }
+
+ throw new global::System.ArgumentException("The value cannot be an empty string or composed entirely of whitespace.", nameof(sectionName));
+ }
+#else
+ global::System.ArgumentNullException.ThrowIfNull(builder);
+ global::System.ArgumentException.ThrowIfNullOrWhiteSpace(sectionName);
+#endif
+
+ _ = builder.ConfigureHostConfiguration(configBuilder => configBuilder.AddBuildMetadata(sectionName))
+ .ConfigureServices((hostBuilderContext, serviceCollection) =>
+ global::Microsoft.Extensions.DependencyInjection.BuildMetadataServiceCollectionExtensions.AddBuildMetadata(serviceCollection, hostBuilderContext.Configuration.GetSection(sectionName)));
+
+ return builder;
+ }
+
+ public static TBuilder UseBuildMetadata(this TBuilder builder, string sectionName = DefaultSectionName)
+ where TBuilder : global::Microsoft.Extensions.Hosting.IHostApplicationBuilder
+ {
+#if !NET
+ if (builder is null)
+ {
+ throw new global::System.ArgumentNullException(nameof(builder));
+ }
+
+ if (string.IsNullOrWhiteSpace(sectionName))
+ {
+ if (sectionName is null)
+ {
+ throw new global::System.ArgumentNullException(nameof(sectionName));
+ }
+
+ throw new global::System.ArgumentException("The value cannot be an empty string or composed entirely of whitespace.", nameof(sectionName));
+ }
+#else
+ global::System.ArgumentNullException.ThrowIfNull(builder);
+ global::System.ArgumentException.ThrowIfNullOrWhiteSpace(sectionName);
+#endif
+
+ _ = builder.Configuration.AddBuildMetadata(sectionName);
+ global::Microsoft.Extensions.DependencyInjection.BuildMetadataServiceCollectionExtensions.AddBuildMetadata(builder.Services, builder.Configuration.GetSection(sectionName));
+
+ return builder;
+ }
+
+ public static global::Microsoft.Extensions.Configuration.IConfigurationBuilder AddBuildMetadata(this global::Microsoft.Extensions.Configuration.IConfigurationBuilder builder, string sectionName = DefaultSectionName)
+ {
+#if !NET
+ if (builder is null)
+ {
+ throw new global::System.ArgumentNullException(nameof(builder));
+ }
+
+ if (string.IsNullOrWhiteSpace(sectionName))
+ {
+ if (sectionName is null)
+ {
+ throw new global::System.ArgumentNullException(nameof(sectionName));
+ }
+
+ throw new global::System.ArgumentException("The value cannot be an empty string or composed entirely of whitespace.", nameof(sectionName));
+ }
+#else
+ global::System.ArgumentNullException.ThrowIfNull(builder);
+ global::System.ArgumentException.ThrowIfNullOrWhiteSpace(sectionName);
+#endif
+
+ return builder.Add(new BuildMetadataSource(sectionName));
+ }
+ }
+}
+
+]
diff --git a/test/Generators/Microsoft.Gen.BuildMetadata/Unit/Verified/GeneratorTests.BuildMetadataGenerator_ShouldGenerate_isAzureDevOps=null.verified.txt b/test/Generators/Microsoft.Gen.BuildMetadata/Unit/Verified/GeneratorTests.BuildMetadataGenerator_ShouldGenerate_isAzureDevOps=null.verified.txt
new file mode 100644
index 00000000000..bf0810d20af
--- /dev/null
+++ b/test/Generators/Microsoft.Gen.BuildMetadata/Unit/Verified/GeneratorTests.BuildMetadataGenerator_ShouldGenerate_isAzureDevOps=null.verified.txt
@@ -0,0 +1,134 @@
+[
+//
+#nullable enable
+#pragma warning disable CS1591 // Compensate for https://github.com/dotnet/roslyn/issues/54103
+namespace Microsoft.Extensions.AmbientMetadata
+{
+ [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Gen.BuildMetadata", "VERSION")]
+ internal static class BuildMetadataGeneratedExtensions
+ {
+ private const string DefaultSectionName = "ambientmetadata:build";
+
+ [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Gen.BuildMetadata", "VERSION")]
+ [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)]
+ private sealed class BuildMetadataSource : global::Microsoft.Extensions.Configuration.IConfigurationSource
+ {
+ public string SectionName { get; }
+
+ public BuildMetadataSource(string sectionName)
+ {
+#if !NET
+ if (string.IsNullOrWhiteSpace(sectionName))
+ {
+ if (sectionName is null)
+ {
+ throw new global::System.ArgumentNullException(nameof(sectionName));
+ }
+
+ throw new global::System.ArgumentException("The value cannot be an empty string or composed entirely of whitespace.", nameof(sectionName));
+ }
+#else
+ global::System.ArgumentException.ThrowIfNullOrWhiteSpace(sectionName);
+#endif
+
+ SectionName = sectionName;
+ }
+
+ public global::Microsoft.Extensions.Configuration.IConfigurationProvider Build(global::Microsoft.Extensions.Configuration.IConfigurationBuilder builder)
+ {
+ return new global::Microsoft.Extensions.Configuration.Memory.MemoryConfigurationProvider(new global::Microsoft.Extensions.Configuration.Memory.MemoryConfigurationSource())
+ {
+ { $"{SectionName}:buildid", "TEST_GITHUB_RUNID" },
+ { $"{SectionName}:buildnumber", "TEST_GITHUB_RUNNUMBER" },
+ { $"{SectionName}:sourcebranchname", "TEST_GITHUB_REFNAME" },
+ { $"{SectionName}:sourceversion", "TEST_GITHUB_SHA" },
+ };
+ }
+ }
+
+ public static global::Microsoft.Extensions.Hosting.IHostBuilder UseBuildMetadata(this global::Microsoft.Extensions.Hosting.IHostBuilder builder, string sectionName = DefaultSectionName)
+ {
+#if !NET
+ if (builder is null)
+ {
+ throw new global::System.ArgumentNullException(nameof(builder));
+ }
+
+ if (string.IsNullOrWhiteSpace(sectionName))
+ {
+ if (sectionName is null)
+ {
+ throw new global::System.ArgumentNullException(nameof(sectionName));
+ }
+
+ throw new global::System.ArgumentException("The value cannot be an empty string or composed entirely of whitespace.", nameof(sectionName));
+ }
+#else
+ global::System.ArgumentNullException.ThrowIfNull(builder);
+ global::System.ArgumentException.ThrowIfNullOrWhiteSpace(sectionName);
+#endif
+
+ _ = builder.ConfigureHostConfiguration(configBuilder => configBuilder.AddBuildMetadata(sectionName))
+ .ConfigureServices((hostBuilderContext, serviceCollection) =>
+ global::Microsoft.Extensions.DependencyInjection.BuildMetadataServiceCollectionExtensions.AddBuildMetadata(serviceCollection, hostBuilderContext.Configuration.GetSection(sectionName)));
+
+ return builder;
+ }
+
+ public static TBuilder UseBuildMetadata(this TBuilder builder, string sectionName = DefaultSectionName)
+ where TBuilder : global::Microsoft.Extensions.Hosting.IHostApplicationBuilder
+ {
+#if !NET
+ if (builder is null)
+ {
+ throw new global::System.ArgumentNullException(nameof(builder));
+ }
+
+ if (string.IsNullOrWhiteSpace(sectionName))
+ {
+ if (sectionName is null)
+ {
+ throw new global::System.ArgumentNullException(nameof(sectionName));
+ }
+
+ throw new global::System.ArgumentException("The value cannot be an empty string or composed entirely of whitespace.", nameof(sectionName));
+ }
+#else
+ global::System.ArgumentNullException.ThrowIfNull(builder);
+ global::System.ArgumentException.ThrowIfNullOrWhiteSpace(sectionName);
+#endif
+
+ _ = builder.Configuration.AddBuildMetadata(sectionName);
+ global::Microsoft.Extensions.DependencyInjection.BuildMetadataServiceCollectionExtensions.AddBuildMetadata(builder.Services, builder.Configuration.GetSection(sectionName));
+
+ return builder;
+ }
+
+ public static global::Microsoft.Extensions.Configuration.IConfigurationBuilder AddBuildMetadata(this global::Microsoft.Extensions.Configuration.IConfigurationBuilder builder, string sectionName = DefaultSectionName)
+ {
+#if !NET
+ if (builder is null)
+ {
+ throw new global::System.ArgumentNullException(nameof(builder));
+ }
+
+ if (string.IsNullOrWhiteSpace(sectionName))
+ {
+ if (sectionName is null)
+ {
+ throw new global::System.ArgumentNullException(nameof(sectionName));
+ }
+
+ throw new global::System.ArgumentException("The value cannot be an empty string or composed entirely of whitespace.", nameof(sectionName));
+ }
+#else
+ global::System.ArgumentNullException.ThrowIfNull(builder);
+ global::System.ArgumentException.ThrowIfNullOrWhiteSpace(sectionName);
+#endif
+
+ return builder.Add(new BuildMetadataSource(sectionName));
+ }
+ }
+}
+
+]
diff --git a/test/Libraries/Microsoft.Extensions.AmbientMetadata.Build.Tests/BuildMetadataServiceCollectionExtensionsTests.cs b/test/Libraries/Microsoft.Extensions.AmbientMetadata.Build.Tests/BuildMetadataServiceCollectionExtensionsTests.cs
new file mode 100644
index 00000000000..1ea00d5bcb5
--- /dev/null
+++ b/test/Libraries/Microsoft.Extensions.AmbientMetadata.Build.Tests/BuildMetadataServiceCollectionExtensionsTests.cs
@@ -0,0 +1,99 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System;
+using System.Collections.Generic;
+using FluentAssertions;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
+using Xunit;
+
+namespace Microsoft.Extensions.AmbientMetadata.Test;
+
+public class BuildMetadataServiceCollectionExtensionsTests
+{
+ [Fact]
+ public void GivenAnyNullArgument_ShouldThrowArgumentNullException()
+ {
+ var serviceCollection = new ServiceCollection();
+ var config = new ConfigurationBuilder().Build();
+
+ serviceCollection.Invoking(x => ((IServiceCollection)null!).AddBuildMetadata(config.GetSection(string.Empty)))
+ .Should().Throw();
+
+ serviceCollection.Invoking(x => x.AddBuildMetadata((Action)null!))
+ .Should().Throw();
+
+ serviceCollection.Invoking(x => ((IServiceCollection)null!).AddBuildMetadata(_ => { }))
+ .Should().Throw();
+
+ serviceCollection.Invoking(x => x.AddBuildMetadata((IConfigurationSection)null!))
+ .Should().Throw();
+ }
+
+ [Fact]
+ public void GivenConfigurationSection_ShouldRegisterMetadataFromIt()
+ {
+ // Arrange
+ var testData = new BuildMetadata
+ {
+ BuildId = Guid.NewGuid().ToString(),
+ BuildNumber = "v1.2.3",
+ SourceBranchName = "main",
+ SourceVersion = "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0",
+ };
+
+ var config = new ConfigurationBuilder()
+ .AddInMemoryCollection(new Dictionary
+ {
+ [$"{nameof(BuildMetadata)}:{nameof(BuildMetadata.BuildId)}"] = testData.BuildId,
+ [$"{nameof(BuildMetadata)}:{nameof(BuildMetadata.BuildNumber)}"] = testData.BuildNumber,
+ [$"{nameof(BuildMetadata)}:{nameof(BuildMetadata.SourceBranchName)}"] = testData.SourceBranchName,
+ [$"{nameof(BuildMetadata)}:{nameof(BuildMetadata.SourceVersion)}"] = testData.SourceVersion,
+ })
+ .Build();
+
+ var configurationSection = config
+ .GetSection(nameof(BuildMetadata));
+
+ // Act
+ using var provider = new ServiceCollection()
+ .AddBuildMetadata(configurationSection)
+ .BuildServiceProvider();
+ var metadata = provider
+ .GetRequiredService>().Value;
+
+ // Assert
+ metadata.Should().BeEquivalentTo(testData);
+ }
+
+ [Fact]
+ public void GivenActionDelegate_ShouldRegisterMetadataFromIt()
+ {
+ // Arrange
+ var testData = new BuildMetadata
+ {
+ BuildId = Guid.NewGuid().ToString(),
+ BuildNumber = "v1.2.3",
+ SourceBranchName = "main",
+ SourceVersion = "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0",
+ };
+
+ // Act
+ using var provider = new ServiceCollection()
+ .AddBuildMetadata(m =>
+ {
+ m.BuildId = testData.BuildId;
+ m.BuildNumber = testData.BuildNumber;
+ m.SourceVersion = testData.SourceVersion;
+ m.SourceBranchName = testData.SourceBranchName;
+ })
+ .BuildServiceProvider();
+ var metadata = provider
+ .GetRequiredService>().Value;
+
+ // Assert
+ metadata.Should().BeEquivalentTo(testData);
+ }
+}
diff --git a/test/Libraries/Microsoft.Extensions.AmbientMetadata.Build.Tests/BuildMetadataTests.cs b/test/Libraries/Microsoft.Extensions.AmbientMetadata.Build.Tests/BuildMetadataTests.cs
new file mode 100644
index 00000000000..af0ba390794
--- /dev/null
+++ b/test/Libraries/Microsoft.Extensions.AmbientMetadata.Build.Tests/BuildMetadataTests.cs
@@ -0,0 +1,84 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System;
+using System.Linq;
+using System.Reflection;
+using FluentAssertions;
+using Xunit;
+
+namespace Microsoft.Extensions.AmbientMetadata.Test;
+
+public class BuildMetadataTests
+{
+ [Fact]
+ public void ShouldConstructObject()
+ {
+ var instance = new BuildMetadata();
+ Assert.NotNull(instance);
+ }
+
+ [Fact]
+ public void ShouldHaveAllPropertiesNull()
+ {
+ // Arrange
+ var obj = new BuildMetadata();
+ var properties = obj.GetType().GetProperties().Select(f => f.GetValue(obj)).ToArray();
+
+ properties.Should().OnlyContain(x => x == null);
+ }
+
+ [Fact]
+ public void BuildIdProperty_ShouldSetAndGetCorrectly()
+ {
+ // Arrange
+ var id = Guid.NewGuid().ToString();
+ var metadata = new BuildMetadata
+ {
+ // Act
+ BuildId = id
+ };
+
+ // Assert
+ metadata.BuildId.Should().Be(id);
+ }
+
+ [Fact]
+ public void BuildNumberProperty_ShouldSetAndGetCorrectly()
+ {
+ // Arrange
+ var metadata = new BuildMetadata
+ {
+ BuildNumber = "v1.2.3"
+ };
+
+ // Assert
+ metadata.BuildNumber.Should().Be("v1.2.3");
+ }
+
+ [Fact]
+ public void SourceBranchNameProperty_ShouldSetAndGetCorrectly()
+ {
+ // Arrange
+ var metadata = new BuildMetadata
+ {
+ SourceBranchName = "main"
+ };
+
+ // Assert
+ metadata.SourceBranchName.Should().Be("main");
+ }
+
+ [Fact]
+ public void SourceVersionProperty_ShouldSetAndGetCorrectly()
+ {
+ // Arrange
+ var metadata = new BuildMetadata
+ {
+ SourceVersion = "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0"
+ };
+
+ // Assert
+ metadata.SourceVersion.Should().Be("a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0");
+ }
+}
diff --git a/test/Libraries/Microsoft.Extensions.AmbientMetadata.Build.Tests/ConfigurationBindingQuirkBehaviorTests.cs b/test/Libraries/Microsoft.Extensions.AmbientMetadata.Build.Tests/ConfigurationBindingQuirkBehaviorTests.cs
new file mode 100644
index 00000000000..bd7f27c954c
--- /dev/null
+++ b/test/Libraries/Microsoft.Extensions.AmbientMetadata.Build.Tests/ConfigurationBindingQuirkBehaviorTests.cs
@@ -0,0 +1,84 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Collections.Generic;
+using FluentAssertions;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Hosting.Testing;
+using Microsoft.Extensions.Options;
+using Xunit;
+
+namespace Microsoft.Extensions.AmbientMetadata.Test;
+
+public class ConfigurationBindingQuirkBehaviorTests
+{
+ [Theory]
+ [InlineData("ambientmetadata:build")]
+ [InlineData("customSection:ambientmetadata:build")]
+ public void GivenMetadata_RegistersOptions_HostBuilder(string sectionName)
+ {
+ // When configuration is not available, values are initialized to the default value of string?, which is null
+ var defaultMetadata = new BuildMetadata();
+
+ using var host = CreateUsingHostBuilder(sectionName);
+
+ // We get a BuildMetadata instance with null values for all its properties, despite using empty strings in the configuration
+ // Related issue: https://github.com/dotnet/runtime/issues/62532
+ host.Services.GetRequiredService>().Value.Should().BeEquivalentTo(defaultMetadata);
+ }
+
+ [Theory]
+ [InlineData("ambientmetadata:build")]
+ [InlineData("customSection:ambientmetadata:build")]
+ public void GivenMetadata_RegistersOptions_HostApplicationBuilder(string sectionName)
+ {
+ // When configuration is not available, values are initialized to an empty string
+ var metadataWithEmptyStrings = new BuildMetadata
+ {
+ BuildId = string.Empty,
+ BuildNumber = string.Empty,
+ SourceBranchName = string.Empty,
+ SourceVersion = string.Empty
+ };
+
+ using var host = CreateUsingHostApplicationBuilder(sectionName);
+
+ // We get a BuildMetadata instance with all properties populated with empty strings, as expected
+ host.Services.GetRequiredService>().Value.Should().BeEquivalentTo(metadataWithEmptyStrings);
+ }
+
+ private static IConfigurationBuilder ConfigureInMemoryCollection(IConfigurationBuilder configuration, string sectionName)
+ {
+ return configuration.AddInMemoryCollection(new Dictionary
+ {
+ { $"{sectionName}:BuildId", string.Empty },
+ { $"{sectionName}:BuildNumber", string.Empty },
+ { $"{sectionName}:SourceBranchName", string.Empty },
+ { $"{sectionName}:SourceVersion", string.Empty }
+ });
+ }
+
+ private static IHost CreateUsingHostBuilder(string sectionName)
+ {
+ return FakeHost.CreateBuilder()
+ .ConfigureHostConfiguration(configBuilder =>
+ {
+ _ = ConfigureInMemoryCollection(configBuilder, sectionName);
+ })
+ .ConfigureServices((context, services) =>
+ {
+ _ = services.AddBuildMetadata(context.Configuration.GetSection(sectionName));
+ })
+ .Build();
+ }
+
+ private static IHost CreateUsingHostApplicationBuilder(string sectionName)
+ {
+ var hostBuilder = Host.CreateEmptyApplicationBuilder(new());
+ _ = ConfigureInMemoryCollection(hostBuilder.Configuration, sectionName);
+ _ = hostBuilder.Services.AddBuildMetadata(hostBuilder.Configuration.GetSection(sectionName));
+ return hostBuilder.Build();
+ }
+}
diff --git a/test/Libraries/Microsoft.Extensions.AmbientMetadata.Build.Tests/Microsoft.Extensions.AmbientMetadata.Build.Tests.csproj b/test/Libraries/Microsoft.Extensions.AmbientMetadata.Build.Tests/Microsoft.Extensions.AmbientMetadata.Build.Tests.csproj
new file mode 100644
index 00000000000..7354b158585
--- /dev/null
+++ b/test/Libraries/Microsoft.Extensions.AmbientMetadata.Build.Tests/Microsoft.Extensions.AmbientMetadata.Build.Tests.csproj
@@ -0,0 +1,17 @@
+
+
+ Microsoft.Extensions.AmbientMetadata.Test
+ Unit tests for Microsoft.Extensions.AmbientMetadata.Build.
+ $(NetCoreTargetFrameworks)
+
+
+
+
+
+
+
+
+
+
+
+