Skip to content

[API Proposal]: Introduce Build ambient metadata #6628

@evgenyfedorov2

Description

@evgenyfedorov2

Background and motivation

We already have one Ambient metadata package providing strongly-typed Application metadata. In addition to that, I propose adding one more - Build ambient metadata.

  • This component automatically grabs build information from the CI/CD pipelines, deserializes it into a strong type BuildMetadata and registers it in Dependency Injection container as IOptions<BuildMetadata>.
  • The component uses source generation to collect build information and immediately express it via C# code.
  • Initially, only GitHub Actions and Azure DevOps are supported.
  • It is a brand new component, proposed package name is Microsoft.Extensions.AmbientMetadata.Build.

API Proposal

// 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;

public class BuildMetadata
{
    /// <summary>
    /// Gets or sets the ID of the record for the build, also known as the run ID.
    /// </summary>
    public string? BuildId { get; set; }

    /// <summary>
    /// Gets or sets the name of the completed build, also known as the run number.
    /// </summary>
    public string? BuildNumber { get; set; }

    /// <summary>
    /// Gets or sets the name of the branch in the triggering repo the build was queued for, also known as the ref name.
    /// </summary>
    public string? SourceBranchName { get; set; }

    /// <summary>
    /// Gets or sets the latest version control change that is included in this build, also known as the commit SHA.
    /// </summary>
    public string? SourceVersion { get; set; }

    /// <summary>
    /// Gets or sets the build time in sortable date/time pattern.
    /// This is the time the BuildMetadataGenerator was run.
    /// </summary>
    public string? BuildDateTime { get; set; }
}
// 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.Extensions.DependencyInjection;
using Microsoft.Shared.Diagnostics;

namespace Microsoft.Extensions.DependencyInjection;

/// <summary>
/// Extensions for Build metadata.
/// </summary>
public static class BuildMetadataExtensions
{
    /// <summary>
    /// Adds an instance of <see cref="BuildMetadata"/> to the <see cref="IServiceCollection"/>.
    /// </summary>
    /// <param name="services">The <see cref="IServiceCollection"/> to add the services to.</param>
    /// <param name="section">The configuration section to bind the instance of <see cref="BuildMetadata"/> against.</param>
    /// <returns>The <see cref="IServiceCollection"/> for call chaining.</returns>
    /// <exception cref="ArgumentNullException">The argument <paramref name="services"/> or <paramref name="section"/> is <see langword="null" />.</exception>
    public static IServiceCollection AddBuildMetadata(this IServiceCollection services, IConfigurationSection section)
    {
        _ = Throw.IfNull(services);
        _ = Throw.IfNull(section);

        _ = services
            .AddOptions<BuildMetadata>()
            .Bind(section);

        return services;
    }

    /// <summary>
    /// Adds an instance of <see cref="BuildMetadata"/> to the <see cref="IServiceCollection"/>.
    /// </summary>
    /// <param name="services">The <see cref="IServiceCollection"/> to add the services to.</param>
    /// <param name="configure">The delegate to configure <see cref="BuildMetadata"/> with.</param>
    /// <returns>The <see cref="IServiceCollection"/> for call chaining.</returns>
    /// <exception cref="ArgumentNullException">The argument <paramref name="services"/> or <paramref name="configure"/> is <see langword="null" />.</exception>
    public static IServiceCollection AddBuildMetadata(this IServiceCollection services, Action<BuildMetadata> configure)
    {
        _ = Throw.IfNull(services);
        _ = Throw.IfNull(configure);

        _ = services
            .AddOptions<BuildMetadata>()
            .Configure(configure);

        return services;
    }
}

Source-generated API available to users:

namespace Microsoft.Extensions.AmbientMetadata;

[global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Gen.BuildMetadata", "9.8.0.0")]
internal static class BuildMetadataGeneratedExtensions
{
  private const string DefaultSectionName = "ambientmetadata:build";
  public static IConfigurationBuilder AddBuildMetadata(this IConfigurationBuilder builder, string sectionName = DefaultSectionName);
  public static IHostBuilder UseBuildMetadata(this IHostBuilder builder, string sectionName = DefaultSectionName);
  public static TBuilder UseBuildMetadata<TBuilder>(this TBuilder builder, string sectionName = DefaultSectionName) where TBuilder : IHostApplicationBuilder;
}

API Usage

// set up hosting
var hostBuilder = Host.CreateEmptyApplicationBuilder(new());
hostBuilder.UseBuildMetadata();
using IHost host = hostBuilder.Build();

// get metadata directly:
var metadataOptions = host.Services.GetRequiredService<IOptions<BuildMetadata>>();
Console.WriteLine(metadataOptions.Value.BuildId);

// or inject it in a constructor:
public class MyService
{
     private readonly BuildMetadata_metadata;
     public MyService(IOptions<BuildMetadata> metadataOptions)
     {
         _metadata = metadataOptions.Value;
     }
}
Example of generated code
// <auto-generated/>
#nullable enable
#pragma warning disable CS1591 // Compensate for https://github.com/dotnet/roslyn/issues/54103
namespace Microsoft.Extensions.AmbientMetadata
{
    using System;
    using System.ComponentModel;
    using System.Diagnostics.CodeAnalysis;
    using Microsoft.Extensions.Configuration;
    using Microsoft.Extensions.Configuration.Memory;
    using Microsoft.Extensions.DependencyInjection;
    using Microsoft.Extensions.Hosting;

    [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Gen.BuildMetadata", "9.8.0.0")]
    internal static class BuildMetadataGeneratedExtensions
    {
        private const string DefaultSectionName = "ambientmetadata:build";

        [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Gen.BuildMetadata", "9.8.0.0")]
        [EditorBrowsable(EditorBrowsableState.Never)]
        private sealed class BuildMetadataSource : IConfigurationSource
        {
            public string SectionName { get; }

            public BuildMetadataSource(string sectionName)
            {
#if NETFRAMEWORK
                if (string.IsNullOrWhiteSpace(sectionName))
                {
                    if (sectionName is null)
                    {
                        throw new ArgumentNullException(nameof(sectionName));
                    }

                    throw new ArgumentException("The value cannot be an empty string or composed entirely of whitespace.", nameof(sectionName));
                }
#else
                ArgumentException.ThrowIfNullOrWhiteSpace(sectionName);
#endif

                SectionName = sectionName;
            }

            public IConfigurationProvider Build(IConfigurationBuilder builder)
            {
                return new MemoryConfigurationProvider(new MemoryConfigurationSource())
                {
                    { $"{SectionName}:buildid", "TEST_BUILDID" },
                    { $"{SectionName}:buildnumber", "TEST_BUILDNUMBER" },
                    { $"{SectionName}:sourcebranchname", "TEST_SOURCEBRANCHNAME" },
                    { $"{SectionName}:sourceversion", "TEST_SOURCEVERSION" },
                    { $"{SectionName}:builddatetime", "1970-01-02T10:17:36" },
                };
            }
        }

        public static IHostBuilder UseBuildMetadata(this IHostBuilder builder, string sectionName = DefaultSectionName)
        {
#if NETFRAMEWORK
            if (builder is null)
            {
                throw new ArgumentNullException(nameof(builder));
            }

            if (string.IsNullOrWhiteSpace(sectionName))
            {
                if (sectionName is null)
                {
                    throw new ArgumentNullException(nameof(sectionName));
                }

                throw new ArgumentException("The value cannot be an empty string or composed entirely of whitespace.", nameof(sectionName));
            }
#else
            ArgumentNullException.ThrowIfNull(builder);
            ArgumentException.ThrowIfNullOrWhiteSpace(sectionName);
#endif

            _ = builder.ConfigureHostConfiguration(configBuilder => configBuilder.AddBuildMetadata(sectionName))
                .ConfigureServices((hostBuilderContext, serviceCollection) =>
                    serviceCollection.AddBuildMetadata(hostBuilderContext.Configuration.GetSection(sectionName)));

            return builder;
        }

        public static TBuilder UseBuildMetadata<TBuilder>(this TBuilder builder, string sectionName = DefaultSectionName)
            where TBuilder : IHostApplicationBuilder
        {
#if NETFRAMEWORK
            if (builder is null)
            {
                throw new ArgumentNullException(nameof(builder));
            }

            if (string.IsNullOrWhiteSpace(sectionName))
            {
                if (sectionName is null)
                {
                    throw new ArgumentNullException(nameof(sectionName));
                }

                throw new ArgumentException("The value cannot be an empty string or composed entirely of whitespace.", nameof(sectionName));
            }
#else
            ArgumentNullException.ThrowIfNull(builder);
            ArgumentException.ThrowIfNullOrWhiteSpace(sectionName);
#endif

            _ = builder.Configuration.AddBuildMetadata(sectionName);
            _ = builder.Services.AddBuildMetadata(builder.Configuration.GetSection(sectionName));

            return builder;
        }

        public static IConfigurationBuilder AddBuildMetadata(this IConfigurationBuilder builder, string sectionName = DefaultSectionName)
        {
#if NETFRAMEWORK
            if (builder is null)
            {
                throw new ArgumentNullException(nameof(builder));
            }

            if (string.IsNullOrWhiteSpace(sectionName))
            {
                if (sectionName is null)
                {
                    throw new ArgumentNullException(nameof(sectionName));
                }

                throw new ArgumentException("The value cannot be an empty string or composed entirely of whitespace.", nameof(sectionName));
            }
#else
            ArgumentNullException.ThrowIfNull(builder);
            ArgumentException.ThrowIfNullOrWhiteSpace(sectionName);
#endif

            return builder.Add(new BuildMetadataSource(sectionName));
        }
    }
}

Alternative Designs

No response

Risks

As suggested here, there is a risk of breaking the reproducible builds principle, because such properties as BuildDateTime, BuildNumber, and BuildId get new values on every build. However, it is the user application code which will break the principle, not .NET.

Metadata

Metadata

Labels

Type

No type

Projects

No projects

Milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions