File: src\WriteBuildOutputProps.cs
Web Access
Project: Microsoft.DotNet.SourceBuild.Tasks.csproj (Microsoft.DotNet.SourceBuild.Tasks)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using Microsoft.Build.Framework;
using Microsoft.Build.Utilities;
using NuGet.Packaging;
using NuGet.Packaging.Core;
using System;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;

namespace Microsoft.DotNet.SourceBuild.Tasks;

[MSBuildMultiThreadableTask]
public class WriteBuildOutputProps : Task, IMultiThreadableTask
{
    private static readonly Regex InvalidElementNameCharRegex = new Regex(@"(^|[^A-Za-z0-9])(?<FirstPartChar>.)");

    public const string CreationTimePropertyName = "BuildOutputPropsCreationTime";

    /// <summary>Injected by MSBuild so paths resolve against the project directory in multithreaded builds.</summary>
    public TaskEnvironment TaskEnvironment { get; set; } = TaskEnvironment.Fallback;

    [Required]
    public ITaskItem[] NuGetPackages { get; set; }

    [Required]
    public string OutputPath { get; set; }

    /// <summary>
    /// Adds a second PropertyGroup to the output XML containing a property with the time of
    /// creation in UTC DateTime Ticks. This can be used to track creation time in situations
    /// where file metadata isn't reliable or preserved.
    /// </summary>
    public bool IncludeCreationTimeProperty { get; set; }

    /// <summary>
    /// Properties to add to the build output props, which may not exist as nupkgs.
    /// FOr example, this is used to pass the version of the CLI toolset archives.
    /// 
    /// %(Identity): Package identity.
    /// %(Version): Package version.
    /// </summary>
    public ITaskItem[] ExtraProperties { get; set; }

    /// <summary>
    /// Additional assets to be added to the build output props.
    /// i.e. /bin/obj/x64/Release/blobs/Toolset/3.0.100
    /// This parameter is the <pathToAsset>/<assetName> portion only, and the asset
    /// must be in a <AdditionalAssetDir>/<assetVersion> folder.
    /// </summary>
    public string[] AdditionalAssetDirs { get; set; }

    public override bool Execute()
    {
        PackageIdentity[] latestPackages = NuGetPackages
            .Select(item =>
            {
                using (var reader = new PackageArchiveReader(item.GetMetadata("FullPath")))
                {
                    return reader.GetIdentity();
                }
            })
            .GroupBy(identity => identity.Id)
            .Select(g => g.OrderBy(id => id.Version).Last())
            .OrderBy(id => id.Id)
            .ToArray();

        var additionalAssets = (AdditionalAssetDirs ?? new string[0])
            // Whitespace has to be filtered out before resolving: Windows path normalization trims trailing
            // spaces, so a whitespace-only entry would resolve to the project directory itself and be picked
            // up as a real asset directory. Unresolved, Directory.Exists simply returned false for it.
            .Where(dir => !string.IsNullOrWhiteSpace(dir))
            .Select(dir => TaskEnvironment.GetAbsolutePath(dir))
            .Where(dir => Directory.Exists(dir))
            .Where(dir => Directory.EnumerateDirectories(dir).Any())
            .Select(dir => new {
                Name = new DirectoryInfo(dir).Name + "Version",
                Version = new DirectoryInfo(TaskEnvironment.GetAbsolutePath(Directory.EnumerateDirectories(dir).OrderBy(s => s).Last())).Name
            }).ToArray();

        Directory.CreateDirectory(Path.GetDirectoryName(TaskEnvironment.GetAbsolutePath(OutputPath)));

        using (var outStream = File.Open(TaskEnvironment.GetAbsolutePath(OutputPath), FileMode.Create))
        using (var sw = new StreamWriter(outStream, new UTF8Encoding(false)))
        {
            sw.WriteLine(@"<?xml version=""1.0"" encoding=""utf-8""?>");
            sw.WriteLine(@"<Project ToolsVersion=""14.0"" xmlns=""http://schemas.microsoft.com/developer/msbuild/2003"">");
            sw.WriteLine(@"  <PropertyGroup>");
            foreach (PackageIdentity packageIdentity in latestPackages)
            {
                string propertyName = GetPropertyName(packageIdentity.Id);
                sw.WriteLine($"    <{propertyName}>{packageIdentity.Version}</{propertyName}>");

                propertyName = GetAlternatePropertyName(packageIdentity.Id);
                sw.WriteLine($"    <{propertyName}>{packageIdentity.Version}</{propertyName}>");
            }
            foreach (var extraProp in ExtraProperties ?? Enumerable.Empty<ITaskItem>())
            {
                string propertyName = extraProp.GetMetadata("Identity");
                bool doNotOverwrite = false;
                string overwriteCondition = string.Empty;
                if (bool.TryParse(extraProp.GetMetadata("DoNotOverwrite"), out doNotOverwrite) && doNotOverwrite)
                {
                    overwriteCondition = $" Condition=\"'$({propertyName})' == ''\"";
                }
                sw.WriteLine($"    <{propertyName}{overwriteCondition}>{extraProp.GetMetadata("Version")}</{propertyName}>");
            }
            foreach (var additionalAsset in additionalAssets)
            {
                sw.WriteLine($"    <{additionalAsset.Name}>{additionalAsset.Version}</{additionalAsset.Name}>");
            }
            sw.WriteLine(@"  </PropertyGroup>");
            if (IncludeCreationTimeProperty)
            {
                sw.WriteLine(@"  <PropertyGroup>");
                sw.WriteLine($@"    <{CreationTimePropertyName}>{DateTime.UtcNow.Ticks}</{CreationTimePropertyName}>");
                sw.WriteLine(@"  </PropertyGroup>");
            }
            sw.WriteLine(@"</Project>");
        }

        return true;
    }

    public static string GetPropertyName(string id)
    {
        string formattedId = InvalidElementNameCharRegex.Replace(
            id,
            match => match.Groups?["FirstPartChar"].Value.ToUpperInvariant()
                ?? string.Empty);

        return $"{formattedId}PackageVersion";
    }

    public static string GetAlternatePropertyName(string id)
    {
        string formattedId = InvalidElementNameCharRegex.Replace(
            id,
            match => match.Groups?["FirstPartChar"].Value.ToUpperInvariant()
                ?? string.Empty);

        return $"{formattedId}Version";
    }
}