Release tools (#1648)
authorJuan Hoyos <juan.hoyos@microsoft.com>
Thu, 12 Nov 2020 00:48:21 +0000 (16:48 -0800)
committerGitHub <noreply@github.com>
Thu, 12 Nov 2020 00:48:21 +0000 (16:48 -0800)
* Add gather-drop wrapper script
* Add convenience dotnet scripts
* Add NuGet publishing script
* Add core of release tool
* Change behavior to stage files in there's a staging path injected
* Fix manifest aka ms link - strip out extensions
* Fix file stream mode
* Add predictable publishing path to manifest
* Add retry and verification
* Add tools section to the manifest.
* Add GitHub release script
* Verification of input parameters
* Add simple documentation on the release tool

36 files changed:
.gitignore
dotnet.cmd [new file with mode: 0644]
dotnet.sh [new file with mode: 0644]
eng/release/DiagnosticsReleaseTool/Common/FileSharePublisher.cs [new file with mode: 0644]
eng/release/DiagnosticsReleaseTool/Common/NugetLayoutWorker.cs [new file with mode: 0644]
eng/release/DiagnosticsReleaseTool/Common/PassThroughLayoutWorker.cs [new file with mode: 0644]
eng/release/DiagnosticsReleaseTool/Common/SymbolPackageLayoutWorker.cs [new file with mode: 0644]
eng/release/DiagnosticsReleaseTool/Common/ZipLayoutWorker.cs [new file with mode: 0644]
eng/release/DiagnosticsReleaseTool/Config.cs [new file with mode: 0644]
eng/release/DiagnosticsReleaseTool/Core/FileMapping.cs [new file with mode: 0644]
eng/release/DiagnosticsReleaseTool/Core/FileMetadata.cs [new file with mode: 0644]
eng/release/DiagnosticsReleaseTool/Core/FileReleaseData.cs [new file with mode: 0644]
eng/release/DiagnosticsReleaseTool/Core/FileWithCdnData.cs [new file with mode: 0644]
eng/release/DiagnosticsReleaseTool/Core/ILayoutWorker.cs [new file with mode: 0644]
eng/release/DiagnosticsReleaseTool/Core/IManifestGenerator.cs [new file with mode: 0644]
eng/release/DiagnosticsReleaseTool/Core/IPublisher.cs [new file with mode: 0644]
eng/release/DiagnosticsReleaseTool/Core/IReleaseVerifier.cs [new file with mode: 0644]
eng/release/DiagnosticsReleaseTool/Core/LayoutWorkerResult.cs [new file with mode: 0644]
eng/release/DiagnosticsReleaseTool/Core/Release.cs [new file with mode: 0644]
eng/release/DiagnosticsReleaseTool/Core/ReleaseMetadata.cs [new file with mode: 0644]
eng/release/DiagnosticsReleaseTool/Core/SingleFileResult.cs [new file with mode: 0644]
eng/release/DiagnosticsReleaseTool/DarcHelpers.cs [new file with mode: 0644]
eng/release/DiagnosticsReleaseTool/DiagnosticsManifestGenerator.cs [new file with mode: 0644]
eng/release/DiagnosticsReleaseTool/DiagnosticsReleaseCommandLine.cs [new file with mode: 0644]
eng/release/DiagnosticsReleaseTool/DiagnosticsReleaseCommandLineUtil.cs [new file with mode: 0644]
eng/release/DiagnosticsReleaseTool/DiagnosticsReleaseRunner.cs [new file with mode: 0644]
eng/release/DiagnosticsReleaseTool/DiagnosticsReleaseTool.csproj [new file with mode: 0644]
eng/release/DiagnosticsReleaseTool/DiagnosticsRepoHelpers.cs [new file with mode: 0644]
eng/release/DiagnosticsReleaseTool/README.md [new file with mode: 0644]
eng/release/DiagnosticsReleaseTool/logging.json [new file with mode: 0644]
eng/release/Directory.Build.props [new file with mode: 0644]
eng/release/Directory.Build.targets [new file with mode: 0644]
eng/release/Scripts/AcquireBuild.ps1 [new file with mode: 0644]
eng/release/Scripts/GenerateGithubRelease.ps1 [new file with mode: 0644]
eng/release/Scripts/PublishToNuget.ps1 [new file with mode: 0644]
eng/release/tool-list.json [new file with mode: 0644]

index 484c88f0d9cebb124644a079d95dcbf0f1381fe0..35750237228aa2e7e6269a8f21edb0b9a091bc22 100644 (file)
@@ -12,6 +12,7 @@
 [Aa]rtifacts/
 [Dd]ebug/
 [Rr]elease/
+!eng/[Rr]elease
 [Bb]in/
 [Oo]bj/
 [Pp]ackages/
@@ -20,6 +21,7 @@ x64/
 .dotnet-test/
 .packages/
 .tools/
+.vscode/
 
 # Per-user project properties
 launchSettings.json
diff --git a/dotnet.cmd b/dotnet.cmd
new file mode 100644 (file)
index 0000000..d6d2588
--- /dev/null
@@ -0,0 +1,23 @@
+@echo off
+setlocal
+
+powershell -ExecutionPolicy ByPass -NoProfile -Command "& { . '%~dp0eng\common\tools.ps1'; InitializeDotNetCli $true $true }"
+
+if NOT [%ERRORLEVEL%] == [0] (
+  echo Failed to install or invoke dotnet... 1>&2
+  exit /b %ERRORLEVEL%
+)
+
+set /p dotnetPath=<%~dp0artifacts\toolset\sdk.txt
+
+:: Clear the 'Platform' env variable for this session, as it's a per-project setting within the build, and
+:: misleading value (such as 'MCD' in HP PCs) may lead to build breakage (issue: #69).
+set Platform=
+
+:: Don't resolve runtime, shared framework, or SDK from other locations to ensure build determinism
+set DOTNET_MULTILEVEL_LOOKUP=0
+
+:: Disable first run since we want to control all package sources
+set DOTNET_SKIP_FIRST_TIME_EXPERIENCE=1
+
+call "%dotnetPath%\dotnet.exe" %*
diff --git a/dotnet.sh b/dotnet.sh
new file mode 100644 (file)
index 0000000..a612eba
--- /dev/null
+++ b/dotnet.sh
@@ -0,0 +1,27 @@
+#!/usr/bin/env bash
+
+source="${BASH_SOURCE[0]}"
+# resolve $SOURCE until the file is no longer a symlink
+while [[ -h $source ]]; do
+  scriptroot="$( cd -P "$( dirname "$source" )" && pwd )"
+  source="$(readlink "$source")"
+
+  # if $source was a relative symlink, we need to resolve it relative to the path where the
+  # symlink file was located
+  [[ $source != /* ]] && source="$scriptroot/$source"
+done
+scriptroot="$( cd -P "$( dirname "$source" )" && pwd )"
+
+# Don't resolve runtime, shared framework, or SDK from other locations to ensure build determinism
+export DOTNET_MULTILEVEL_LOOKUP=0
+
+# Disable first run since we want to control all package sources
+export DOTNET_SKIP_FIRST_TIME_EXPERIENCE=1
+
+source $scriptroot/eng/common/tools.sh
+
+InitializeDotNetCli true # Install
+__dotnetDir=${_InitializeDotNetCli}
+
+dotnetPath=${__dotnetDir}/dotnet
+${dotnetPath} "$@"
diff --git a/eng/release/DiagnosticsReleaseTool/Common/FileSharePublisher.cs b/eng/release/DiagnosticsReleaseTool/Common/FileSharePublisher.cs
new file mode 100644 (file)
index 0000000..70f1442
--- /dev/null
@@ -0,0 +1,128 @@
+using System;
+using System.Buffers;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace ReleaseTool.Core
+{
+    public class FileSharePublisher : IPublisher
+    {
+        private const int MaxRetries = 5;
+        private const int DelayMsec = 100;
+
+        private readonly string _sharePath;
+
+        public FileSharePublisher(string sharePath)
+        {
+            _sharePath = sharePath;
+        }
+
+        public void Dispose() { }
+
+        public async Task<string> PublishFileAsync(FileMapping fileMap, CancellationToken ct)
+        {
+            // TODO: Be resilient to "can't cancel case".
+            string destinationUri = Path.Combine(_sharePath, fileMap.RelativeOutputPath);
+            FileInfo fi = null;
+
+            try
+            {
+                fi = new FileInfo(destinationUri);
+            }
+            catch (Exception)
+            {
+                // TODO: We probably want logging here.
+                return null;
+            }
+
+            int retries = 0;
+            int delay = 0;
+            bool completed = false;
+
+            do
+            {
+                await Task.Delay(delay, ct);
+                try
+                {
+                    if (fi.Exists && fi.Attributes.HasFlag(FileAttributes.Directory))
+                    {
+                        // Filestream will deal with files, but not directories
+                        Directory.Delete(destinationUri, recursive: true);
+                    }
+
+                    fi.Directory.Create();
+
+                    using var srcStream = new FileStream(fileMap.LocalSourcePath, FileMode.Open, FileAccess.Read);
+                    using var destStream = new FileStream(destinationUri, FileMode.Create, FileAccess.ReadWrite);
+                    await srcStream.CopyToAsync(destStream, ct);
+
+                    destStream.Position = 0;
+                    srcStream.Position = 0;
+
+                    completed = await VerifyFileStreamsMatchAsync(srcStream, destStream, ct);
+                }
+                catch (IOException ex) when (!(ex is PathTooLongException || ex is FileNotFoundException))
+                {
+                    /* Retry IO exceptions */
+                }
+                catch (Exception)
+                {
+                    return null;
+                }
+
+                retries++;
+                delay = delay * 2 + DelayMsec;
+            } while (retries < MaxRetries && !completed);
+
+            return destinationUri;
+        }
+
+        private async Task<bool> VerifyFileStreamsMatchAsync(FileStream srcStream, FileStream destStream, CancellationToken ct)
+        {
+            if (srcStream.Length != destStream.Length)
+            {
+                return false;
+            }
+
+            using IMemoryOwner<byte> memOwnerSrc = MemoryPool<byte>.Shared.Rent(minBufferSize: 16_384);
+            using IMemoryOwner<byte> memOwnerDest = MemoryPool<byte>.Shared.Rent(minBufferSize: 16_384);
+            Memory<byte> memSrc = memOwnerSrc.Memory;
+            Memory<byte> memDest = memOwnerDest.Memory;
+
+            int bytesProcessed = 0;
+            int srcBytesRemainingFromPrevRead = 0;
+            int destBytesRemainingFromPrevRead = 0;
+
+            while (bytesProcessed != srcStream.Length)
+            {
+                int srcBytesRead = await srcStream.ReadAsync(memSrc.Slice(srcBytesRemainingFromPrevRead), ct);
+                srcBytesRead += srcBytesRemainingFromPrevRead;
+                int destBytesRead = await destStream.ReadAsync(memDest.Slice(destBytesRemainingFromPrevRead), ct);
+                destBytesRead += destBytesRemainingFromPrevRead;
+
+                int bytesToCompare = Math.Min(srcBytesRead, destBytesRead);
+
+                if (bytesToCompare == 0)
+                {
+                    return false;
+                }
+
+                bytesProcessed += bytesToCompare;
+                srcBytesRemainingFromPrevRead = srcBytesRead - bytesToCompare;
+                destBytesRemainingFromPrevRead = destBytesRead - bytesToCompare;
+
+                bool isChunkEquals = memDest.Span.Slice(0, bytesToCompare).SequenceEqual(memSrc.Span.Slice(0, bytesToCompare));
+                if (!isChunkEquals)
+                {
+                    return false;
+                }
+
+                memSrc.Slice(bytesToCompare, srcBytesRemainingFromPrevRead).CopyTo(memSrc);
+                memDest.Slice(bytesToCompare, destBytesRemainingFromPrevRead).CopyTo(memDest);
+            }
+
+            return true;
+        }
+    }
+}
\ No newline at end of file
diff --git a/eng/release/DiagnosticsReleaseTool/Common/NugetLayoutWorker.cs b/eng/release/DiagnosticsReleaseTool/Common/NugetLayoutWorker.cs
new file mode 100644 (file)
index 0000000..c48f522
--- /dev/null
@@ -0,0 +1,17 @@
+using System.IO;
+
+namespace ReleaseTool.Core
+{
+    public sealed class NugetLayoutWorker : PassThroughLayoutWorker
+    {
+        public NugetLayoutWorker(string stagingPath) : base(
+            shouldHandleFileFunc: ShouldHandleFile,
+            getRelativePublishPathFromFileFunc: GetNugetPublishRelativePath,
+            getMetadataForFileFunc: (_) => new FileMetadata(FileClass.Nuget),
+            stagingPath
+        ) {}
+
+        private static bool ShouldHandleFile(FileInfo file) => file.Extension == ".nupkg" && !file.Name.EndsWith(".symbols.nupkg");
+        private static string GetNugetPublishRelativePath(FileInfo file) => FileMetadata.GetDefaultCatgoryForClass(FileClass.Nuget);
+    }
+}
\ No newline at end of file
diff --git a/eng/release/DiagnosticsReleaseTool/Common/PassThroughLayoutWorker.cs b/eng/release/DiagnosticsReleaseTool/Common/PassThroughLayoutWorker.cs
new file mode 100644 (file)
index 0000000..508fc90
--- /dev/null
@@ -0,0 +1,63 @@
+using System;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace ReleaseTool.Core
+{
+    public class PassThroughLayoutWorker : ILayoutWorker
+    {
+        private readonly Func<FileInfo, bool> _shouldHandleFileFunc;
+        private readonly Func<FileInfo, string> _getRelativePublishPathFromFileFunc;
+        private readonly Func<FileInfo, FileMetadata> _getMetadataForFileFunc;
+        private readonly string _stagingPath;
+
+        public PassThroughLayoutWorker(
+            Func<FileInfo, bool> shouldHandleFileFunc,
+            Func<FileInfo, string> getRelativePublishPathFromFileFunc,
+            Func<FileInfo, FileMetadata> getMetadataForFileFunc,
+            string stagingPath)
+        {
+
+            _shouldHandleFileFunc = shouldHandleFileFunc ?? (_ => true);
+
+            _getRelativePublishPathFromFileFunc = getRelativePublishPathFromFileFunc ?? (file => Path.Combine(FileMetadata.GetDefaultCatgoryForClass(FileClass.Unknown), file.Name));
+
+            _getMetadataForFileFunc = getMetadataForFileFunc ?? (_ => new FileMetadata(FileClass.Unknown));
+
+            _stagingPath = stagingPath;
+        }
+
+        public void Dispose() {}
+
+        public async ValueTask<LayoutWorkerResult> HandleFileAsync(FileInfo file, CancellationToken ct)
+        {
+            if (!_shouldHandleFileFunc(file))
+            {
+                return new LayoutWorkerResult(LayoutResultStatus.FileNotHandled);
+            }
+
+            string publishReleasePath = Path.Combine(_getRelativePublishPathFromFileFunc(file), file.Name);
+
+            string localPath = file.FullName;
+
+            if (_stagingPath is not null)
+            {
+                localPath = Path.Combine(_stagingPath, publishReleasePath);
+                Directory.CreateDirectory(Path.GetDirectoryName(localPath));
+                using (FileStream srcStream = new FileStream(file.FullName, FileMode.Open, FileAccess.Read))
+                using (FileStream destStream = new FileStream(localPath, FileMode.Create, FileAccess.Write))
+                {
+                    await srcStream.CopyToAsync(destStream, ct);
+                }
+            }
+
+            var fileMap = new FileMapping(localPath, publishReleasePath);
+            var metadata = _getMetadataForFileFunc(file);
+
+            return new LayoutWorkerResult(
+                    LayoutResultStatus.FileHandled,
+                    new SingleFileResult(fileMap, metadata));
+        }
+    }
+}
\ No newline at end of file
diff --git a/eng/release/DiagnosticsReleaseTool/Common/SymbolPackageLayoutWorker.cs b/eng/release/DiagnosticsReleaseTool/Common/SymbolPackageLayoutWorker.cs
new file mode 100644 (file)
index 0000000..ae06296
--- /dev/null
@@ -0,0 +1,17 @@
+using System.IO;
+
+namespace ReleaseTool.Core
+{
+    public class SymbolPackageLayoutWorker : PassThroughLayoutWorker
+    {
+        public SymbolPackageLayoutWorker(string stagingPath) : base(
+            shouldHandleFileFunc: ShouldHandleFile,
+            getRelativePublishPathFromFileFunc: GetSymbolPackagePublishRelativePath,
+            getMetadataForFileFunc: (_) => new FileMetadata(FileClass.SymbolPackage),
+            stagingPath
+        ) {}
+
+        private static bool ShouldHandleFile(FileInfo file) => file.Name.EndsWith(".symbols.nupkg");
+        private static string GetSymbolPackagePublishRelativePath(FileInfo file) => FileMetadata.GetDefaultCatgoryForClass(FileClass.SymbolPackage);
+    }
+}
\ No newline at end of file
diff --git a/eng/release/DiagnosticsReleaseTool/Common/ZipLayoutWorker.cs b/eng/release/DiagnosticsReleaseTool/Common/ZipLayoutWorker.cs
new file mode 100644 (file)
index 0000000..4054793
--- /dev/null
@@ -0,0 +1,92 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace ReleaseTool.Core
+{
+    public sealed class ZipLayoutWorker : ILayoutWorker
+    {
+        private Func<FileInfo, bool> _shouldHandleFileFunc;
+        private Func<FileInfo, FileInfo, string> _getRelativePathFromZipAndInnerFileFunc;
+        private Func<FileInfo, FileInfo, FileMetadata> _getMetadataForInnerFileFunc;
+        private readonly string _stagingPath;
+
+        public ZipLayoutWorker(
+            Func<FileInfo, bool> shouldHandleFileFunc,
+            Func<FileInfo, FileInfo, string> getRelativePathFromZipAndInnerFileFunc,
+            Func<FileInfo, FileInfo, FileMetadata> getMetadataForInnerFileFunc,
+            string stagingPath)
+        {
+
+            _shouldHandleFileFunc = shouldHandleFileFunc ?? (file => file.Extension == ".zip");
+
+            _getRelativePathFromZipAndInnerFileFunc = getRelativePathFromZipAndInnerFileFunc ?? ((zipFile, innerFile) => Path.Combine(zipFile.Name, innerFile.Name));
+
+            _getMetadataForInnerFileFunc = getMetadataForInnerFileFunc ?? ((_, _) => new FileMetadata(FileClass.Blob));
+
+            _stagingPath = stagingPath;
+        }
+
+        public void Dispose() { }
+
+        public async ValueTask<LayoutWorkerResult> HandleFileAsync(FileInfo file, CancellationToken ct)
+        {
+            if (!_shouldHandleFileFunc(file))
+            {
+                return new LayoutWorkerResult(LayoutResultStatus.FileNotHandled);
+            }
+
+            DirectoryInfo unzipDirInfo = null;
+
+            try {
+                do {
+                    string tempUnzipPath = Path.Join(Path.GetTempPath(), Path.GetRandomFileName());
+                    unzipDirInfo = new DirectoryInfo(tempUnzipPath);
+                } while(unzipDirInfo.Exists);
+
+                unzipDirInfo.Create();
+                // TODO: Do we really want to block because of unzipping. We could use ZipArchive.
+                System.IO.Compression.ZipFile.ExtractToDirectory(file.FullName, unzipDirInfo.FullName);
+            }
+            catch(Exception ex) when (ex is IOException || ex is System.Security.SecurityException)
+            {
+                return new LayoutWorkerResult(LayoutResultStatus.Error);
+            }
+
+
+            var filesInToolBundleToPublish = new List<(FileMapping, FileMetadata)>();
+
+            foreach (FileInfo extractedFile in unzipDirInfo.EnumerateFiles("*", SearchOption.AllDirectories))
+            {
+                if (ct.IsCancellationRequested)
+                {
+                    return new LayoutWorkerResult(LayoutResultStatus.Error);
+                }
+
+                string relativePath = _getRelativePathFromZipAndInnerFileFunc(file, extractedFile);
+                relativePath = Path.Combine(relativePath, extractedFile.Name);
+
+                string localPath = extractedFile.FullName;
+
+                if (_stagingPath is not null)
+                {
+                    localPath = Path.Combine(_stagingPath, relativePath);
+                    Directory.CreateDirectory(Path.GetDirectoryName(localPath));
+                    using (FileStream srcStream = new FileStream(extractedFile.FullName, FileMode.Open, FileAccess.Read))
+                    using (FileStream destStream = new FileStream(localPath, FileMode.Create, FileAccess.Write))
+                    {
+                        await srcStream.CopyToAsync(destStream, ct);
+                    }
+                }
+
+                var fileMap = new FileMapping(localPath, relativePath);
+                FileMetadata metadata = _getMetadataForInnerFileFunc(file, extractedFile);
+                filesInToolBundleToPublish.Add((fileMap, metadata));
+            }
+
+            return new LayoutWorkerResult(LayoutResultStatus.FileHandled, filesInToolBundleToPublish);
+        }
+    }
+}
\ No newline at end of file
diff --git a/eng/release/DiagnosticsReleaseTool/Config.cs b/eng/release/DiagnosticsReleaseTool/Config.cs
new file mode 100644 (file)
index 0000000..b4f9b16
--- /dev/null
@@ -0,0 +1,23 @@
+using System.IO;
+
+namespace DiagnosticsReleaseTool.Impl
+{
+    internal class Config
+    {
+        public FileInfo ToolManifest { get; }
+        public bool ShouldVerifyManifest { get; }
+        public DirectoryInfo DropPath { get; }
+        public DirectoryInfo StagingDirectory { get; }
+        public string PublishPath { get; }
+
+        public Config(FileInfo toolManifest, bool verifyToolManifest,
+            DirectoryInfo inputDropPath, DirectoryInfo stagingDirectory, string publishPath)
+        {
+            ToolManifest = toolManifest;
+            ShouldVerifyManifest = verifyToolManifest;
+            DropPath = inputDropPath;
+            StagingDirectory = stagingDirectory;
+            PublishPath = publishPath;
+        }
+    }
+}
\ No newline at end of file
diff --git a/eng/release/DiagnosticsReleaseTool/Core/FileMapping.cs b/eng/release/DiagnosticsReleaseTool/Core/FileMapping.cs
new file mode 100644 (file)
index 0000000..0b210ef
--- /dev/null
@@ -0,0 +1,15 @@
+namespace ReleaseTool.Core
+{
+    public struct FileMapping
+    {
+        public FileMapping(string localSourcePath, string relativeOutputPath)
+        {
+            LocalSourcePath = localSourcePath;
+            RelativeOutputPath = relativeOutputPath;
+        }
+
+        public string LocalSourcePath { get; }
+
+        public string RelativeOutputPath { get; }
+    }
+}
\ No newline at end of file
diff --git a/eng/release/DiagnosticsReleaseTool/Core/FileMetadata.cs b/eng/release/DiagnosticsReleaseTool/Core/FileMetadata.cs
new file mode 100644 (file)
index 0000000..130f65e
--- /dev/null
@@ -0,0 +1,81 @@
+using System;
+
+namespace ReleaseTool.Core
+{
+    public enum FileClass
+    {
+        Blob,
+        Nuget,
+        SymbolPackage,
+        Unknown
+    }
+
+    public struct FileMetadata
+    {
+        public readonly FileClass Class { get; }
+
+        public readonly  string AssetCategory { get; }
+
+        public readonly bool ShouldPublishToCdn { get; }
+
+        public readonly string Rid { get; }
+
+        public readonly string Sha512 { get; }
+
+        // TODO: Add a metadata bag for Key,Value pairs.
+
+        public FileMetadata(FileClass fileClass) 
+            : this(fileClass, GetDefaultCatgoryForClass(fileClass)) {}
+
+        public FileMetadata(FileClass fileClass, string assetCategory) 
+            : this(fileClass, assetCategory, shouldPublishToCdn: false, rid: "any", sha512: null) {}
+
+        public FileMetadata(FileClass fileClass, string assetCategory,  bool shouldPublishToCdn, string rid, string sha512)
+        {
+            if (string.IsNullOrEmpty(assetCategory))
+            {
+                throw new ArgumentException("AssetCategory for file can't be null or empty");
+            }
+
+            if (sha512 is not null)
+            {
+                bool validSha = sha512.Length == 128;
+                for (int idx = 0; idx < sha512.Length && validSha; idx++)
+                {
+                    char x = char.ToLower(sha512[idx]);
+                    validSha |= (char.IsDigit(x) || (x >= 'a' && x <= 'f'));
+                }
+
+                if (!validSha)
+                {
+                    throw new ArgumentException("SHA512 is invalid");
+                }
+            }
+
+            if (sha512 is null && shouldPublishToCdn)
+            {
+                throw new InvalidOperationException("SHA512 can't be null if file needs CDN publishing");
+            }
+
+            Class = fileClass;
+            AssetCategory = assetCategory;
+            ShouldPublishToCdn = shouldPublishToCdn;
+            Sha512 = sha512;
+            Rid = rid;
+        }
+
+        public static string GetDefaultCatgoryForClass(FileClass fileClass) => fileClass switch
+        {
+            FileClass.Blob => "BlobAssets",
+            FileClass.Nuget => "NugetAssets",
+            FileClass.SymbolPackage => "SymbolNugetAssets",
+            FileClass.Unknown => "UnknownAssets",
+            _ => "UnknownAssets"
+        };
+
+        public override string ToString()
+        {
+            return $"Class: {Class}, Category: {AssetCategory}, CDN: {ShouldPublishToCdn}, RID: {Rid}";
+        }
+    }
+}
\ No newline at end of file
diff --git a/eng/release/DiagnosticsReleaseTool/Core/FileReleaseData.cs b/eng/release/DiagnosticsReleaseTool/Core/FileReleaseData.cs
new file mode 100644 (file)
index 0000000..1c360a6
--- /dev/null
@@ -0,0 +1,19 @@
+namespace ReleaseTool.Core
+{
+    public class FileReleaseData
+    {
+        public FileReleaseData(FileMapping fileMap, FileMetadata fileMetadata)
+            : this(fileMap, fileMetadata, null) {}
+
+        private FileReleaseData(FileMapping fileMap, FileMetadata fileMetadata, string publishUri)
+        {
+            FileMap = fileMap;
+            FileMetadata = fileMetadata;
+            PublishUri = publishUri;
+        }
+
+        public FileMapping FileMap { get; }
+        public FileMetadata FileMetadata { get; }
+        public string PublishUri { get; internal set; }
+    }
+}
\ No newline at end of file
diff --git a/eng/release/DiagnosticsReleaseTool/Core/FileWithCdnData.cs b/eng/release/DiagnosticsReleaseTool/Core/FileWithCdnData.cs
new file mode 100644 (file)
index 0000000..a246e0c
--- /dev/null
@@ -0,0 +1,24 @@
+namespace ReleaseTool.Core
+{
+    public struct FileWithCdnData
+    {
+        public string Comment { get; }
+
+        public string FilePath { get; }
+
+        public string Sha512 { get; }
+
+        public string PublishUrlSubPath { get; }
+
+        public string AkaMsLink { get; }
+
+        public FileWithCdnData(string filePath, string sha512, string publishUrlSubPath, string akaMsLink, string comment)
+        {
+            FilePath = filePath;
+            Sha512 = sha512;
+            PublishUrlSubPath = publishUrlSubPath;
+            AkaMsLink = akaMsLink;
+            Comment = comment;
+        }
+    }
+}
\ No newline at end of file
diff --git a/eng/release/DiagnosticsReleaseTool/Core/ILayoutWorker.cs b/eng/release/DiagnosticsReleaseTool/Core/ILayoutWorker.cs
new file mode 100644 (file)
index 0000000..999b058
--- /dev/null
@@ -0,0 +1,12 @@
+using System;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace ReleaseTool.Core
+{
+    public interface ILayoutWorker : IDisposable
+    {
+        ValueTask<LayoutWorkerResult> HandleFileAsync(FileInfo file, CancellationToken ct);
+    }
+}
\ No newline at end of file
diff --git a/eng/release/DiagnosticsReleaseTool/Core/IManifestGenerator.cs b/eng/release/DiagnosticsReleaseTool/Core/IManifestGenerator.cs
new file mode 100644 (file)
index 0000000..acaf548
--- /dev/null
@@ -0,0 +1,10 @@
+using System;
+using System.Collections.Generic;
+
+namespace ReleaseTool.Core
+{
+    public interface IManifestGenerator : IDisposable
+    {
+        System.IO.Stream GenerateManifest(IEnumerable<FileReleaseData> filesToRelease);
+    }
+}
\ No newline at end of file
diff --git a/eng/release/DiagnosticsReleaseTool/Core/IPublisher.cs b/eng/release/DiagnosticsReleaseTool/Core/IPublisher.cs
new file mode 100644 (file)
index 0000000..a1b8cc9
--- /dev/null
@@ -0,0 +1,11 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace ReleaseTool.Core
+{
+    public interface IPublisher : IDisposable
+    {
+        Task<string> PublishFileAsync(FileMapping fileData, CancellationToken ct);
+    }
+}
\ No newline at end of file
diff --git a/eng/release/DiagnosticsReleaseTool/Core/IReleaseVerifier.cs b/eng/release/DiagnosticsReleaseTool/Core/IReleaseVerifier.cs
new file mode 100644 (file)
index 0000000..066f1cb
--- /dev/null
@@ -0,0 +1,10 @@
+using System;
+using System.Collections.Generic;
+
+namespace ReleaseTool.Core
+{
+    public interface IReleaseVerifier : IDisposable
+    {
+        bool VerifyFiles(IEnumerable<FileReleaseData> filestoRelease);
+    }
+}
\ No newline at end of file
diff --git a/eng/release/DiagnosticsReleaseTool/Core/LayoutWorkerResult.cs b/eng/release/DiagnosticsReleaseTool/Core/LayoutWorkerResult.cs
new file mode 100644 (file)
index 0000000..8d4790e
--- /dev/null
@@ -0,0 +1,27 @@
+using System.Collections.Generic;
+
+namespace ReleaseTool.Core
+{
+    public enum LayoutResultStatus
+    {
+        Error,
+        FileNotHandled,
+        FileHandled,
+    }
+
+    public struct LayoutWorkerResult
+    {
+        public LayoutResultStatus Status { get; }
+        public IEnumerable<(FileMapping fileMap, FileMetadata fileMetadata)> LayoutDataEnumerable { get; }
+
+        public LayoutWorkerResult(LayoutResultStatus status,
+            IEnumerable<(FileMapping fileMap, FileMetadata fileMetadata)> fileLayoutDataEnumerable)
+        {
+            Status = status;
+            LayoutDataEnumerable = Status == LayoutResultStatus.FileHandled && fileLayoutDataEnumerable is not null
+                                                ? fileLayoutDataEnumerable
+                                                : System.Linq.Enumerable.Empty<(FileMapping, FileMetadata)>();
+        }
+        public LayoutWorkerResult(LayoutResultStatus status) : this(status, null) {}
+    }
+}
\ No newline at end of file
diff --git a/eng/release/DiagnosticsReleaseTool/Core/Release.cs b/eng/release/DiagnosticsReleaseTool/Core/Release.cs
new file mode 100644 (file)
index 0000000..0209de0
--- /dev/null
@@ -0,0 +1,260 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+
+namespace ReleaseTool.Core
+{
+    public class Release : IDisposable
+    {
+        // TODO: there might be a need to expose this for multiple product roots.
+        private readonly DirectoryInfo _productBuildPath;
+        private readonly List<ILayoutWorker> _layoutWorkers;
+        private readonly List<IReleaseVerifier> _verifiers;
+        private readonly IPublisher _publisher;
+        private readonly IManifestGenerator _manifestGenerator;
+        private readonly string _manifestSavePath;
+
+        private readonly List<FileReleaseData> _filesToRelease;
+        private ILogger _logger;
+
+        public Release(DirectoryInfo productBuildPath, 
+            List<ILayoutWorker> layoutWorkers, List<IReleaseVerifier> verifiers,
+            IPublisher publisher, IManifestGenerator manifestGenerator, string manifestSavePath)
+        {
+            if (string.IsNullOrEmpty(_productBuildPath))
+            {
+                throw new ArgumentException("Product build path can't be empty or null.");
+            }
+
+            if (layoutWorkers is null)
+            {
+                throw new ArgumentException($"{nameof(layoutWorkers)} can't be null.");
+            }
+
+            if (publisher is null)
+            {
+                throw new ArgumentException($"{nameof(publisher)} can't be null.");
+            }
+
+            if (manifestGenerator is null)
+            {
+                throw new ArgumentException($"{nameof(manifestGenerator)} can't be null.");
+            }
+
+            _productBuildPath = productBuildPath;
+            _layoutWorkers = layoutWorkers;
+            _verifiers = verifiers;
+            _publisher = publisher;
+            _manifestGenerator = manifestGenerator;
+            _manifestSavePath = manifestSavePath ?? Path.Join(Path.GetTempPath(), Path.GetRandomFileName(), "releaseManifest");
+            _filesToRelease = new List<FileReleaseData>();
+            _logger = null;
+            // TODO: Validate drop to publish exists.
+        }
+
+        public void UseLogger(ILogger logger)
+        {
+            _logger = logger;
+        }
+
+        public async Task<int> RunAsync(CancellationToken ct)
+        {
+            EnsureLoggerAvailable();
+
+            int unusedFiles = 0;
+            try
+            {
+                unusedFiles = await LayoutFilesAsync(ct);
+
+                // TODO: Implement switch to ignore files that are not used as option.
+                if (unusedFiles != 0)
+                {
+                    return unusedFiles;
+                }
+
+                // TODO: Verification
+
+                unusedFiles = await PublishFiles(ct);
+                if (unusedFiles != 0)
+                {
+                    return unusedFiles;
+                }
+
+                return await GenerateAndPublishManifestAsync(ct);
+            }
+            catch (TaskCanceledException)
+            {
+               _logger.LogError("Cancellation issued.");
+                return -1;
+            }
+            catch (AggregateException agEx)
+            {
+               _logger.LogError("Aggregate Exception");
+
+                foreach (var ex in agEx.InnerExceptions)
+                   _logger.LogError(ex, "Inner Exception");
+
+                return -1;
+            }
+            catch (Exception ex)
+            {
+               _logger.LogError(ex, "Exception");
+                return -1;
+            }
+        }
+
+        private void EnsureLoggerAvailable() => _logger ??= NullLogger.Instance;
+
+        private async Task<int> GenerateAndPublishManifestAsync(CancellationToken ct)
+        {
+            // Manifest
+            using IDisposable scope = _logger.BeginScope("Manifest Generation");
+            Stream manifestStream = _manifestGenerator.GenerateManifest(_filesToRelease);
+            var fi = new FileInfo(_manifestSavePath);
+            fi.Directory.Create();
+
+            using (FileStream fs = fi.Open(FileMode.Create, FileAccess.Write))
+            {
+                await manifestStream.CopyToAsync(fs, ct);
+            }
+
+            _logger.LogInformation("Manifest saved to {_manifestSavePath}", _manifestSavePath);
+
+            // We save the manifest at the root.
+            string manifestPublishPath = await _publisher.PublishFileAsync(
+                new FileMapping(_manifestSavePath, fi.Name),
+                ct
+            );
+
+            if (manifestPublishPath is null)
+            {
+                _logger.LogError("Couldn't publish manifest");
+                return -1;
+            }
+
+            _logger.LogInformation("Published manifest to {manifestPublishPath}", manifestPublishPath);
+
+            return 0;
+        }
+
+        private async Task<int> PublishFiles(CancellationToken ct)
+        {
+            int unpublishedFiles = 0;
+
+            using IDisposable scope = _logger.BeginScope("Publishing files");
+            _logger.LogInformation("Publishing {fileCount} files", _filesToRelease.Count);
+
+            foreach (FileReleaseData releaseData in _filesToRelease)
+            {
+                if (ct.IsCancellationRequested)
+                {
+                    _logger.LogError("Cancellation issued.");
+                    return -1;
+                }
+
+                string sourcePath = releaseData.FileMap.LocalSourcePath;
+                string relOutputPath = releaseData.FileMap.RelativeOutputPath;
+                string publishUri = await _publisher.PublishFileAsync(releaseData.FileMap, ct);
+                if (publishUri is null)
+                {
+                    _logger.LogWarning("Failed to publish {sourcePath}");
+                    unpublishedFiles++;
+                }
+                else
+                {
+                    _logger.LogTrace("Published {sourcePath} to relative path {relOutputPath} at {publishUri}", sourcePath, relOutputPath, publishUri);
+                    releaseData.PublishUri = publishUri;
+                }
+            }
+
+            return unpublishedFiles;
+        }
+
+        private async Task<int> LayoutFilesAsync(CancellationToken ct)
+        {
+            int unhandledFiles = 0;
+            var relativePublishPathsUsed = new HashSet<string>();
+
+            using var scope = _logger.BeginScope("Laying out files");
+
+            _logger.LogInformation("Laying out files from {_productBuildPath}", _productBuildPath);
+
+            // TODO: Make this parallel using Task.Run + semaphore to batch process files. Need to make collections concurrent or have single
+            //       queue to aggregate results.
+            // TODO: The file enumeration should have the posibility to inject a custom enumerator. Useful in case there's only subsets of files.
+            //       For example, shipping only files. 
+            foreach (FileInfo file in _productBuildPath.EnumerateFiles("*", SearchOption.AllDirectories))
+            {
+                bool isProcessed = false;
+                foreach (ILayoutWorker worker in _layoutWorkers)
+                {
+                    if (ct.IsCancellationRequested)
+                    {
+                        _logger.LogError($"Cancellation issued.");
+                        return -1;
+                    }
+
+                    // Do we want to parallelize the number of workers?
+                    LayoutWorkerResult layoutResult = await worker.HandleFileAsync(file, ct);
+
+                    if (layoutResult.Status == LayoutResultStatus.Error)
+                    {
+                        _logger.LogError($"Error handling file.");
+                        return -1;
+                    }
+
+                    if (layoutResult.Status == LayoutResultStatus.FileHandled)
+                    {
+                        if (isProcessed)
+                        {
+                            // TODO: Might be worth to relax this limitation. It just needs to turn the
+                            //      source -> fileData relationship to something like source -> List<FileData>).
+                            _logger.LogError("File {file} is getting handled by several workers.", file);
+                            return -1;
+                        }
+
+                        isProcessed = true;
+
+                        foreach ((FileMapping fileMap, FileMetadata fileMetadata) in layoutResult.LayoutDataEnumerable)
+                        {
+                            string srcPath = fileMap.LocalSourcePath;
+                            string dstPath = fileMap.RelativeOutputPath;
+                            if (relativePublishPathsUsed.Contains(dstPath))
+                            {
+                                _logger.LogError("File {srcPath} is getting published to relative path {dstPath} which is already in use.", srcPath, dstPath);
+                                return -1;
+                            }
+                            relativePublishPathsUsed.Add(dstPath);
+                            _logger.LogTrace("{srcPath} -> {dstPath} [{fileMetadata}]", srcPath, dstPath, fileMetadata);
+                            _filesToRelease.Add(new FileReleaseData(fileMap, fileMetadata));
+                        }
+                    }
+                }
+
+                if (!isProcessed)
+                {
+                    _logger.LogWarning("File not handled {file}", file);
+                    unhandledFiles++;
+                }
+            }
+
+            return unhandledFiles;
+        }
+
+        public void Dispose()
+        {
+            foreach(ILayoutWorker lw in _layoutWorkers)
+                lw.Dispose();
+
+            foreach(IReleaseVerifier rv in _verifiers)
+                rv.Dispose();
+
+            _publisher.Dispose();
+            _manifestGenerator.Dispose();
+        }
+    }
+}
\ No newline at end of file
diff --git a/eng/release/DiagnosticsReleaseTool/Core/ReleaseMetadata.cs b/eng/release/DiagnosticsReleaseTool/Core/ReleaseMetadata.cs
new file mode 100644 (file)
index 0000000..dffb08b
--- /dev/null
@@ -0,0 +1,24 @@
+namespace ReleaseTool.Core
+{
+    public class ReleaseMetadata
+    {
+        public string ReleaseVersion { get; }
+        public string RepoUrl { get; }
+        public string Branch { get; }
+        public string Commit { get; }
+        public string DateProduced { get; }
+        public string BuildNumber { get; }
+        public int BarBuildId { get; }
+
+        public ReleaseMetadata(string releaseVersion, string repoUrl, string branch, string commit, string dateProduced, string buildNumber, int barBuildId)
+        {
+            ReleaseVersion = releaseVersion;
+            RepoUrl = repoUrl;
+            Branch = branch;
+            Commit = commit;
+            DateProduced = dateProduced;
+            BuildNumber = buildNumber;
+            BarBuildId = barBuildId;
+        }
+    }
+}
\ No newline at end of file
diff --git a/eng/release/DiagnosticsReleaseTool/Core/SingleFileResult.cs b/eng/release/DiagnosticsReleaseTool/Core/SingleFileResult.cs
new file mode 100644 (file)
index 0000000..54ddd39
--- /dev/null
@@ -0,0 +1,54 @@
+using System.Collections;
+using System.Collections.Generic;
+
+namespace ReleaseTool.Core
+{
+    public struct SingleFileResult : IEnumerable<(FileMapping fileMap, FileMetadata fileMetadata)>
+    {
+        private readonly (FileMapping fileMap, FileMetadata fileMetadata) _fileData;
+
+        public IEnumerator<(FileMapping fileMap, FileMetadata fileMetadata)> GetEnumerator() => GetInnerEnumerator();
+
+        IEnumerator IEnumerable.GetEnumerator() => GetInnerEnumerator();
+
+        public SingleFileResult(FileMapping fileMap, FileMetadata fileMetadata)
+        {
+            _fileData = (fileMap, fileMetadata);
+        }
+
+        private Enumerator GetInnerEnumerator()
+        {
+            return new Enumerator(this);
+        }
+
+        public struct Enumerator : IEnumerator<(FileMapping fileMap, FileMetadata fileMetadata)>
+        {
+            private bool _consumed;
+
+            public Enumerator(SingleFileResult singleFileResults) : this()
+            {
+                Current = singleFileResults._fileData;
+            }
+
+            public (FileMapping fileMap, FileMetadata fileMetadata) Current { get; private set; }
+
+            object IEnumerator.Current => Current;
+
+            public void Dispose()
+            {
+            }
+
+            public bool MoveNext()
+            {
+                bool cur = _consumed;
+                _consumed = true;
+                return !cur;
+            }
+
+            public void Reset()
+            {
+                _consumed = false;
+            }
+        }
+    }
+}
\ No newline at end of file
diff --git a/eng/release/DiagnosticsReleaseTool/DarcHelpers.cs b/eng/release/DiagnosticsReleaseTool/DarcHelpers.cs
new file mode 100644 (file)
index 0000000..204f120
--- /dev/null
@@ -0,0 +1,97 @@
+using System;
+using System.IO;
+using System.Linq;
+using System.Text.Json;
+using ReleaseTool.Core;
+
+namespace DiagnosticsReleaseTool.Util
+{
+    internal class DarcHelpers
+    {
+        private readonly DirectoryInfo _dropPath;
+
+        public string ReleaseFilePath { get; }
+
+        public string ManifestFilePath { get; }
+
+        public DarcHelpers(DirectoryInfo dropPath)
+        {
+            _dropPath = dropPath;
+            ReleaseFilePath = Path.Join(_dropPath.FullName, "release.json");
+            ManifestFilePath = Path.Join(_dropPath.FullName, "manifest.json");
+
+            if (!dropPath.Exists
+                || !File.Exists(ReleaseFilePath)
+                || !File.Exists(ManifestFilePath))
+            {
+                throw new InvalidOperationException($"{_dropPath.FullName} in not a valid darc drop");
+            }
+        }
+
+        internal ReleaseMetadata GetDropMetadata(string repoUrl)
+        {
+            string releaseVersion;
+            using (Stream darcReleaseFile = File.OpenRead(ReleaseFilePath))
+            using (JsonDocument jsonDoc = JsonDocument.Parse(darcReleaseFile))
+            {
+                JsonElement releaseVersionElement = jsonDoc.RootElement[0].GetProperty("release");
+                releaseVersion = releaseVersionElement.GetString();
+            }
+
+            using (Stream darcManifest = File.OpenRead(ManifestFilePath))
+            using (JsonDocument jsonDoc = JsonDocument.Parse(darcManifest))
+            {
+                // TODO: Schema validation.
+                JsonElement buildList = jsonDoc.RootElement.GetProperty("builds");
+
+                // TODO: This should be using Uri.Compare...
+                var repoBuilds = buildList.EnumerateArray()
+                                          .Where(build => build.GetProperty("repo").GetString() == repoUrl);
+
+                if (repoBuilds.Count() != 1)
+                {
+                    throw new InvalidOperationException(
+                        $"There's either no build for {repoUrl} or more than one. Can't retrieve metadata.");
+                }
+
+                JsonElement build = repoBuilds.ElementAt(0);
+
+                // TODO: If any of these were to fail...
+                var releaseMetadata = new ReleaseMetadata(
+                    releaseVersion: releaseVersion,
+                    repoUrl: repoUrl,
+                    branch: build.GetProperty("branch").GetString(),
+                    commit: build.GetProperty("commit").GetString(),
+                    dateProduced: build.GetProperty("produced").GetString(),
+                    buildNumber: build.GetProperty("buildNumber").GetString(),
+                    barBuildId: build.GetProperty("barBuildId").GetInt32()
+                );
+
+                return releaseMetadata;
+            }
+        }
+
+        internal DirectoryInfo GetShippingDirectoryForProject(string projectName)
+        {
+            using (Stream darcManifest = File.OpenRead(ReleaseFilePath))
+            using (JsonDocument jsonDoc = JsonDocument.Parse(darcManifest))
+            {
+                // TODO: There's a lot of error validation that should go here. We are basically assuming a
+                // pretty stable schema.
+                JsonElement productList = jsonDoc.RootElement[0].GetProperty("products");
+
+                var directoryList = productList.EnumerateArray()
+                                               .Where(prod => prod.GetProperty("name").GetString() == projectName)
+                                               .Select(prod => prod.GetProperty("fileshare"));
+
+                if (directoryList.Count() != 1)
+                {
+                    throw new InvalidOperationException(
+                        $"There's either no product named {projectName} or more than one in the drop.");
+                }
+
+                return new DirectoryInfo(directoryList.ElementAt(0).GetString());
+            }
+        }
+    }
+}
\ No newline at end of file
diff --git a/eng/release/DiagnosticsReleaseTool/DiagnosticsManifestGenerator.cs b/eng/release/DiagnosticsReleaseTool/DiagnosticsManifestGenerator.cs
new file mode 100644 (file)
index 0000000..ea96732
--- /dev/null
@@ -0,0 +1,208 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Text.Json;
+using System.Text.RegularExpressions;
+using DiagnosticsReleaseTool.Util;
+using Microsoft.Extensions.Logging;
+using ReleaseTool.Core;
+
+namespace DiagnosticsReleaseTool.Impl
+{
+    internal class DiagnosticsManifestGenerator : IManifestGenerator
+    {
+        private readonly ReleaseMetadata _productReleaseMetadata;
+        private readonly JsonDocument _assetManifestManifestDom;
+        private readonly ILogger _logger;
+
+        public DiagnosticsManifestGenerator(ReleaseMetadata productReleaseMetadata, FileInfo toolManifest, ILogger logger)
+        {
+            _productReleaseMetadata = productReleaseMetadata;
+            string manifestContent = File.ReadAllText(toolManifest.FullName);
+            _assetManifestManifestDom = JsonDocument.Parse(manifestContent);
+            _logger = logger;
+        }
+
+        public void Dispose()
+        {
+            _assetManifestManifestDom.Dispose();
+        }
+
+        public Stream GenerateManifest(IEnumerable<FileReleaseData> filesProcessed)
+        {
+            var stream = new MemoryStream();
+
+            using (var writer = new Utf8JsonWriter(stream, new JsonWriterOptions{ Indented = true }))
+            {
+                writer.WriteStartObject();
+
+                WriteMetadata(writer);
+
+                WritePublishingInstructions(writer, filesProcessed);
+                WriteBundledTools(writer, filesProcessed);
+                WriteNugetShippingPackages(writer, filesProcessed);
+
+                writer.WriteEndObject();
+            }
+            stream.Position = 0;
+            return stream;
+        }
+
+        private void WriteBundledTools(Utf8JsonWriter writer, IEnumerable<FileReleaseData> filesProcessed)
+        {
+            writer.WritePropertyName(DiagnosticsRepoHelpers.BundledToolsCategory);
+            writer.WriteStartArray();
+
+            IEnumerable<FileReleaseData> bundledTools = 
+                filesProcessed.Where(
+                    file => file.FileMetadata.AssetCategory == DiagnosticsRepoHelpers.BundledToolsCategory);
+
+            foreach (FileReleaseData fileToRelease in bundledTools)
+            {
+                writer.WriteStartObject();
+                writer.WriteString("ToolName", Path.GetFileNameWithoutExtension(fileToRelease.FileMap.LocalSourcePath));
+                writer.WriteString("Rid", fileToRelease.FileMetadata.Rid);
+                writer.WriteString("PublishRelativePath", fileToRelease.FileMap.RelativeOutputPath);
+                writer.WriteString("PublishedPath", fileToRelease.PublishUri);
+                writer.WriteEndObject();
+            }
+
+            writer.WriteEndArray();
+        }
+
+        private void WriteNugetShippingPackages(Utf8JsonWriter writer, IEnumerable<FileReleaseData> filesProcessed)
+        {
+            writer.WritePropertyName(FileMetadata.GetDefaultCatgoryForClass(FileClass.Nuget));
+            writer.WriteStartArray();
+
+            IEnumerable<FileReleaseData> nugetFiles = filesProcessed.Where(file => file.FileMetadata.Class == FileClass.Nuget);
+
+            foreach (FileReleaseData fileToRelease in nugetFiles)
+            {
+                writer.WriteStartObject();
+                writer.WriteString("PublishRelativePath", fileToRelease.FileMap.RelativeOutputPath);
+                writer.WriteString("PublishedPath", fileToRelease.PublishUri);
+                writer.WriteEndObject();
+            }
+
+            writer.WriteEndArray();
+        }
+
+        private void WritePublishingInstructions(Utf8JsonWriter writer, IEnumerable<FileReleaseData> filesProcessed)
+        {
+            writer.WritePropertyName("PublishInstructions");
+            writer.WriteStartArray();
+
+            var options = new JsonSerializerOptions
+            {
+                IgnoreNullValues = true,
+                WriteIndented = true
+            };
+
+            foreach (FileReleaseData fileToRelease in filesProcessed)
+            {
+                if (fileToRelease.FileMetadata.ShouldPublishToCdn)
+                {
+                    FileWithCdnData fileWithCdnData = GetFileWithCdnData(fileToRelease);
+                    JsonSerializer.Serialize<FileWithCdnData>(writer, fileWithCdnData, options);
+                }
+            }
+
+            writer.WriteEndArray();
+        }
+
+        private FileWithCdnData GetFileWithCdnData(FileReleaseData fileToRelease)
+        {
+            bool categoryHasData = _assetManifestManifestDom.RootElement.TryGetProperty(
+                fileToRelease.FileMetadata.AssetCategory,
+                out JsonElement dataForCategory);
+
+            string akaLink = null;
+            if (categoryHasData && dataForCategory.TryGetProperty("AkaMsSchema", out JsonElement linkSchema))
+            {
+                akaLink = GenerateLinkFromMetadata(fileToRelease, linkSchema.GetString());
+            }
+
+            string subPath = GenerateSubpath(fileToRelease);
+
+            return new FileWithCdnData(
+                filePath: fileToRelease.PublishUri,
+                sha512: fileToRelease.FileMetadata.Sha512,
+                publishUrlSubPath: subPath,
+                akaMsLink: akaLink,
+                comment: null
+            );
+        }
+
+        private string GenerateSubpath(FileReleaseData fileToRelease)
+        {
+            var fi = new FileInfo(fileToRelease.FileMap.LocalSourcePath);
+            using var hash = System.Security.Cryptography.SHA256Managed.Create();
+            var enc = System.Text.Encoding.UTF8;
+            byte[] hashResult = hash.ComputeHash(enc.GetBytes(fileToRelease.FileMap.RelativeOutputPath));
+            string pathHash = BitConverter.ToString(hashResult).Replace("-", String.Empty);
+
+            return $"{_productReleaseMetadata.ReleaseVersion}/{pathHash}/{fi.Name}";
+        }
+
+        private static readonly Regex s_akaMsMetadataMatcher = new Regex(
+                $@"<(?<metadata>[a-zA-Z]\w*)>",
+                RegexOptions.Compiled | RegexOptions.ExplicitCapture);
+
+        private string GenerateLinkFromMetadata(FileReleaseData fileToRelease, string linkSchema)
+        {
+            var fi = new FileInfo(fileToRelease.FileMap.LocalSourcePath);
+            string link = linkSchema;
+            //TODO: Revisit for perf if necessary...
+            MatchCollection results = s_akaMsMetadataMatcher.Matches(linkSchema);
+            foreach (Match match in results)
+            {
+                if(!match.Groups.TryGetValue("metadata", out Group metadataGroup))
+                {
+                    // Give up if the catpturing failed
+                    return null;
+                }
+
+                string metadataValue = metadataGroup.Value switch {
+                    "FileName" => fi.Name,
+                    "FileNameNoExt" => Path.GetFileNameWithoutExtension(fi.Name),
+                    "Rid" => fileToRelease.FileMetadata.Rid,
+                    "Sha512" => fileToRelease.FileMetadata.Sha512,
+                    "AssetCategory" => fileToRelease.FileMetadata.AssetCategory,
+                    _ => null
+                };
+
+                if (string.IsNullOrEmpty(metadataValue))
+                {
+                    _logger.LogWarning("Can't replace metadata {metadataGroup.Value} for {fileToRelease.FileMap.LocalSourcePath}",
+                        metadataGroup.Value, fileToRelease.FileMap.LocalSourcePath);
+                    return null;
+                }
+                else
+                {
+                    link = link.Replace($"<{metadataGroup.Value}>", metadataValue);
+                }
+            }
+
+            if (Uri.TryCreate(link, UriKind.Absolute, out Uri uriResult) 
+                && (uriResult.Scheme == Uri.UriSchemeHttp || uriResult.Scheme == Uri.UriSchemeHttps))
+            {
+                return link;
+            }
+
+            return null;
+        }
+
+        private void WriteMetadata(Utf8JsonWriter writer)
+        {
+            // There's no way to obtain the json DOM for an object...
+            byte[] metadataJsonObj = JsonSerializer.SerializeToUtf8Bytes<ReleaseMetadata>(_productReleaseMetadata);
+            JsonDocument metadataDoc = JsonDocument.Parse(metadataJsonObj);
+            foreach(var element in metadataDoc.RootElement.EnumerateObject())
+            {
+                element.WriteTo(writer);
+            }
+        }
+    }
+}
\ No newline at end of file
diff --git a/eng/release/DiagnosticsReleaseTool/DiagnosticsReleaseCommandLine.cs b/eng/release/DiagnosticsReleaseTool/DiagnosticsReleaseCommandLine.cs
new file mode 100644 (file)
index 0000000..fbd38ea
--- /dev/null
@@ -0,0 +1,84 @@
+using System.CommandLine;
+using System.CommandLine.Builder;
+using System.CommandLine.Invocation;
+using System.CommandLine.Parsing;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+
+using DiagnosticsReleaseTool.Impl;
+
+namespace DiagnosticsReleaseTool.CommandLine
+{
+    class DiagnosticsReleaseCommandLine
+    {
+        static async Task<int> Main(string[] args)
+        {
+            var parser = new CommandLineBuilder()
+                .AddCommand(PrepareRelease())
+                .CancelOnProcessTermination()
+                .UseDefaults()
+                .Build();
+
+            return await parser.InvokeAsync(args);
+        }
+
+        public static Command PrepareRelease() =>
+            new Command(
+                name: "prepare-release",
+                description: "Given a darc drop, generates validated manifests and layouts to initiate a tool release.")
+            {
+                CommandHandler.Create<Config, bool, CancellationToken>(DiagnosticsReleaseRunner.PrepareRelease),
+                // Inputs
+                InputDropPathOption(), ToolManifestPathOption(), 
+                // Toggles
+                ToolManifestVerificationOption(), DiagnosticLoggingOption(),
+                // Outputs
+                StagingPathOption(), PublishPathOption()
+            };
+
+        private static Option<bool> DiagnosticLoggingOption() =>
+            new Option<bool>(
+                aliases: new[] { "-v", "--verbose" },
+                description: "Enables diagnostic logging",
+                getDefaultValue: () => false);
+
+        private static Option ToolManifestPathOption() =>
+            new Option<FileInfo>(
+                aliases: new[] { "--tool-manifest", "-t" },
+                description: "Full path to the manifest of tools and packages to publish.")
+            {
+                IsRequired = true
+            }.ExistingOnly();
+
+        private static Option<bool> ToolManifestVerificationOption() =>
+            new Option<bool>(
+                alias: "--verify-tool-manifest",
+                description: "Verifies that the assets being published match the manifest",
+                getDefaultValue: () => true);
+
+        private static Option<DirectoryInfo> InputDropPathOption() => 
+            new Option<DirectoryInfo>(
+                aliases: new[] { "-i", "--input-drop-path" },
+                description: "Path to drop generated by `darc gather-drop`")
+            {
+                IsRequired = true
+            }.ExistingOnly();
+
+        private static Option StagingPathOption() =>
+            new Option<DirectoryInfo>(
+                aliases: new[] { "--staging-directory", "-s" },
+                description: "Full path to the staging path.",
+                getDefaultValue: () => new DirectoryInfo(
+                    Path.Join(Path.GetTempPath(), Path.GetRandomFileName())))
+            .LegalFilePathsOnly();
+
+        private static Option<string> PublishPathOption() =>
+            new Option<string>(
+                aliases: new[] { "-o", "--publish-path" },
+                description: "Path to publish the generated layout and publishing manifest to.")
+            {
+                IsRequired = true
+            };
+    }
+}
diff --git a/eng/release/DiagnosticsReleaseTool/DiagnosticsReleaseCommandLineUtil.cs b/eng/release/DiagnosticsReleaseTool/DiagnosticsReleaseCommandLineUtil.cs
new file mode 100644 (file)
index 0000000..40a9cbf
--- /dev/null
@@ -0,0 +1,17 @@
+
+using System.CommandLine;
+using System.CommandLine.Invocation;
+
+namespace DiagnosticsReleaseTool.CommandLine
+{
+    public static class DiagnosticsReleaseCommandLineUtil
+    {
+        /// <summary>
+        /// Allows the command handler to be included in the collection initializer.
+        /// </summary>
+        public static void Add(this Command command, ICommandHandler handler)
+        {
+            command.Handler = handler;
+        }
+    }
+}
\ No newline at end of file
diff --git a/eng/release/DiagnosticsReleaseTool/DiagnosticsReleaseRunner.cs b/eng/release/DiagnosticsReleaseTool/DiagnosticsReleaseRunner.cs
new file mode 100644 (file)
index 0000000..f5ecb41
--- /dev/null
@@ -0,0 +1,88 @@
+using System.Collections.Generic;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+
+using DiagnosticsReleaseTool.Util;
+using ReleaseTool.Core;
+
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.Logging;
+
+namespace DiagnosticsReleaseTool.Impl
+{
+    internal class DiagnosticsReleaseRunner
+    {
+        internal const string ManifestName = "publishManifest.json";
+
+        internal async static Task<int> PrepareRelease(Config releaseConfig, bool verbose, CancellationToken ct)
+        {
+            // TODO: This will throw if invalid drop path is given.
+            var darcLayoutHelper = new DarcHelpers(releaseConfig.DropPath);
+
+            ILogger logger = GetDiagLogger(verbose);
+
+            var layoutWorkerList = new List<ILayoutWorker>
+            {
+                // TODO: We may want to inject a logger.
+                new NugetLayoutWorker(stagingPath: null),
+                new SymbolPackageLayoutWorker(stagingPath: null),
+                new ZipLayoutWorker(
+                    shouldHandleFileFunc: DiagnosticsRepoHelpers.IsBundledToolArchive,
+                    getRelativePathFromZipAndInnerFileFunc: DiagnosticsRepoHelpers.GetToolPublishRelativePath,
+                    getMetadataForInnerFileFunc: DiagnosticsRepoHelpers.GetMetadataForToolFile,
+                    stagingPath: null
+                )
+            };
+
+            var verifierList = new List<IReleaseVerifier> { };
+
+            if (releaseConfig.ShouldVerifyManifest)
+            {
+                // TODO: add verifier.
+                // verifierList.Add();
+            }
+
+            // TODO: Probably should use BAR ID instead as an identifier for the metadata to gather.
+            ReleaseMetadata releaseMetadata = darcLayoutHelper.GetDropMetadata(DiagnosticsRepoHelpers.RepositoryName);
+            DirectoryInfo basePublishDirectory = darcLayoutHelper.GetShippingDirectoryForProject(DiagnosticsRepoHelpers.ProductName);
+            string publishManifestPath = Path.Combine(releaseConfig.StagingDirectory.FullName, ManifestName);
+
+            IPublisher releasePublisher = new FileSharePublisher(releaseConfig.PublishPath);
+            IManifestGenerator manifestGenerator = new DiagnosticsManifestGenerator(releaseMetadata, releaseConfig.ToolManifest, logger);
+
+            using var diagnosticsRelease = new Release(
+                productBuildPath: basePublishDirectory,
+                layoutWorkers: layoutWorkerList,
+                verifiers: verifierList,
+                publisher: releasePublisher,
+                manifestGenerator: manifestGenerator,
+                manifestSavePath: publishManifestPath
+            );
+
+            diagnosticsRelease.UseLogger(logger);
+
+            return await diagnosticsRelease.RunAsync(ct);
+        }
+
+        private static ILogger GetDiagLogger(bool verbose)
+        {
+            var loggingConfiguration = new ConfigurationBuilder()
+                .AddJsonFile("logging.json", optional: false, reloadOnChange: false)
+                .Build();
+
+            using var loggerFactory = LoggerFactory.Create(builder =>
+            {
+                builder.AddConfiguration(loggingConfiguration.GetSection("Logging"))
+                    .AddConsole();
+
+                if (verbose)
+                {
+                    builder.AddFilter("DiagnosticsReleaseTool.Impl.DiagnosticsReleaseRunner", LogLevel.Trace);
+                }
+            });
+
+            return loggerFactory.CreateLogger<DiagnosticsReleaseRunner>();
+        }
+    }
+}
\ No newline at end of file
diff --git a/eng/release/DiagnosticsReleaseTool/DiagnosticsReleaseTool.csproj b/eng/release/DiagnosticsReleaseTool/DiagnosticsReleaseTool.csproj
new file mode 100644 (file)
index 0000000..2214e25
--- /dev/null
@@ -0,0 +1,25 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+  <PropertyGroup>
+    <OutputType>Exe</OutputType>
+    <TargetFramework>net5.0</TargetFramework>
+  </PropertyGroup>
+
+  <ItemGroup>
+    <Content Include="logging.json" CopyToPublishDirectory="PreserveNewest">
+      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
+    </Content>
+  </ItemGroup>
+
+  <ItemGroup>
+    <PackageReference Include="Microsoft.Extensions.Configuration" Version="5.0.0-rc.2.20475.5" />
+    <PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="5.0.0-rc.2.20475.5" />
+    <PackageReference Include="Microsoft.Extensions.Logging" Version="5.0.0-rc.2.20475.5" />
+    <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="5.0.0-rc.2.20475.5" />
+    <PackageReference Include="Microsoft.Extensions.Logging.Configuration" Version="5.0.0-rc.2.20475.5" />
+    <PackageReference Include="Microsoft.Extensions.Logging.Console" Version="5.0.0-rc.2.20475.5" />
+
+    <PackageReference Include="System.CommandLine" Version="2.0.0-beta1.20467.2" />
+  </ItemGroup>
+
+</Project>
diff --git a/eng/release/DiagnosticsReleaseTool/DiagnosticsRepoHelpers.cs b/eng/release/DiagnosticsReleaseTool/DiagnosticsRepoHelpers.cs
new file mode 100644 (file)
index 0000000..97b8f59
--- /dev/null
@@ -0,0 +1,91 @@
+using System;
+using System.IO;
+using System.Text.RegularExpressions;
+using ReleaseTool.Core;
+
+namespace DiagnosticsReleaseTool.Util
+{
+    public static class DiagnosticsRepoHelpers
+    {
+        public const string ProductName = "diagnostics";
+        public const string RepositoryName = "https://github.com/dotnet/diagnostics";
+        public static string BundleToolsPathInDrop => System.IO.Path.Combine("diagnostics", "bundledtools");
+        public const string BundledToolsPrefix = "diagnostic-tools-";
+        public const string BundledToolsCategory = "ToolBundleAssets";
+        public const string PdbCategory = "PdbAssets";
+
+        private static readonly Regex s_ridBundledToolsMatcher = new Regex(
+                $@"{BundledToolsPrefix}(?<rid>(\w+-)+\w+)\.zip",
+                RegexOptions.Compiled | RegexOptions.ExplicitCapture);
+
+        private static string GetRidFromBundleZip(FileInfo zipFile)
+        {
+            MatchCollection matches = s_ridBundledToolsMatcher.Matches(zipFile.Name);
+
+            if (matches.Count != 1)
+            {
+                throw new Exception($"Unexpected file name for tool bundle: {zipFile}.");
+            }
+
+            foreach (Match match in matches)
+            {
+                if (!match.Groups.TryGetValue("rid", out Group ridGroup))
+                {
+                    throw new Exception($"Can't extract a RID from {zipFile}.");
+                }
+
+                return ridGroup.Value;
+            }
+
+            throw new Exception($"Unexpected failure in RID extraction from {zipFile}.");
+        }
+
+        public static FileMetadata GetMetadataForToolFile(FileInfo zipFile, FileInfo fileInZip)
+        {
+            string category = fileInZip.Extension switch
+            {
+                ".pdb" => PdbCategory,
+                ".exe" => BundledToolsCategory,
+                "" => BundledToolsCategory,
+                _ => "UnknownAssets"
+            };
+
+            string sha512 = null;
+            string rid = GetRidFromBundleZip(zipFile);
+
+            if (category == BundledToolsCategory)
+            {
+                sha512 = GetSha512(fileInZip.FullName);
+            }
+
+            return new FileMetadata(
+                    FileClass.Blob,
+                    assetCategory: category,
+                    shouldPublishToCdn: category == BundledToolsCategory,
+                    rid: rid,
+                    sha512: sha512);
+        }
+
+        public static string GetToolPublishRelativePath(FileInfo zipFile, FileInfo fileInZip)
+        {
+            return Path.Combine(BundledToolsCategory, GetRidFromBundleZip(zipFile));
+        }
+
+        public static bool IsBundledToolArchive(FileInfo file)
+        {
+            return file.Exists && file.Extension == ".zip"
+                && file.DirectoryName.Contains(BundleToolsPathInDrop)
+                && file.Name.StartsWith(BundledToolsPrefix);
+        }
+
+        public static string GetSha512(string filePath)
+        {
+            using (FileStream stream = System.IO.File.OpenRead(filePath))
+            {
+                var sha = new System.Security.Cryptography.SHA512Managed();
+                byte[] checksum = sha.ComputeHash(stream);
+                return BitConverter.ToString(checksum).Replace("-", String.Empty);
+            }
+        }
+    }
+}
\ No newline at end of file
diff --git a/eng/release/DiagnosticsReleaseTool/README.md b/eng/release/DiagnosticsReleaseTool/README.md
new file mode 100644 (file)
index 0000000..7e33d5b
--- /dev/null
@@ -0,0 +1,67 @@
+# Diagnostics Release Tool
+
+The diagnostics release tool consists of two components. A reusable core, and the specific logic necessary for publishing the tools in this repo.
+
+## The Core functionality
+
+This can be shared by anyone who needs to release files to CDN and generate release manifests. It's comprised of the files in the `Common` and `Core` directories. The main concepts needed to understand.
+
+1. The orchestrator, the `Release` class takes a list of layout workers, a list of verifiers, a publisher, and a manifest generator, and a base directory to publish. Any unhandled exception by any of the components with be logged and the orchestration process will finish.
+2. The layout workers - Implement `ILayoutWorker`, and through `HandleFileAsync` they are expected to return a `LayoutWorkerResult`. It represents both if the file was handled by the worker or an error status, and an enumerable representing the set of source files to relative paths to publish along with some metadata (file type, hash, if it needs to be put in CDN, a category string, a Rid to disambiguate between platform specific assets). This allows for a single file to generate several publishing locations, or to unpack archives and distribute files the files to their respective target destinations. There's a couple caveats:
+   1. Each local file should only be handled by one layout worker. If the file needs to be published to several locations, a single publisher should do it.
+   2. Each file should be handled by at least one worker.
+   3. There are 4 simple implementations of workers that come by default: one for NuGet packages, one for symbol packages, one for pass through blobs (take it from a location and publish it somewhere), and one that unzips and tries to place all files within.
+3. Verifiers - they get a lot of all files that will get published with metadata and target path. The idea is to provide an extension point to verify all and only expected files get published. Currently this functionality is not hooked up.
+4. The publishers - These are supposed take a map of local source path to relative path from publishing location and publish the file. They return the string of the URI where the file got published. A sample one provided published to a network share. 
+5. A manifest generator (`IManifestGenerator`) will receive all the files will have the opportunity to produce a manifest in the format sees fit with all the information provided by the publishing process in a stream that's handed back to the orchestrator. The orchestrator will save the contents of such manifest to file and save it at the root of the publishing location.
+
+## The Diagnostics Specifics.
+
+1. The diagnostics repo uses only the publishers and layout workers that are included in the core.
+2. The repository uses a `darc` generated drop to collect the assets to release. It's important to note only assets marked as shipping will be considered.
+  - Anything that's a NuGet package will get stored in the `NugetAssets` folder of the release.
+  - Anything that's a symbol package will get stored in the `SymbolNugetAssets` folder of the release.
+  - Single file global tools will get stored in the `ToolBundleAssets` directory under the specific tool's RID.
+3. The manifest generator is specific to the diagnostics repo, but it could easily be used in other places with some modification. Particularly the `PublishInstructions` sections can be used to publish files to CDN using the ESRP infrastructure after proper onboarding, as the repository uses the generic format the engineering team requires.  
+   The manifest in the diagnostics repo consists of a JSON file with four sections:
+    - A generic metadata section consisting of key value pairs of useful build data (commit, branch, version, date, etc).
+    - A `PublishInstructions` section - A list containing each file to be published to our CDN. Each file has the path where it got published, its content's SHA512, the relative path to be stored in the CDN, and if needed the AKA.ms link to use. We specify the schema for our the download links in the `tool-list.json`, which we pass in to the manifest generator constructor.
+    - A `ToolBundleAssets` section - A list containing each single file tool published. Each item containing the name of the tool, its RID, the path relative to publish root, and the URI where it got published.
+    - A `NugetAssets` section - A list of all the packages to publish, with each having the relative publishing path and the URI where it got published.
+
+    A sample miniature manifest would be:
+
+    ```json
+    {
+        "ReleaseVersion": "68656-20201030-04",
+        "RepoUrl": "https://github.com/dotnet/diagnostics",
+        "Branch": "refs/heads/release/stable",
+        "Commit": "4d281c71a14e6226ab0bf0c98687db4a5c4217e3",
+        "DateProduced": "2020-10-23T13:21:45\u002B00:00",
+        "BuildNumber": "20201022.2",
+        "BarBuildId": 68656,
+        "PublishInstructions": [
+            {
+                "FilePath": "\\\\random\\share\\68656-20201030-04\\ToolBundleAssets\\linux-arm\\dotnet-counters",
+                "Sha512": "D6722205EA22BC0D1AA2CDCBF4BAF171A1D7D727E4804749DE74C9099230B8EE643A51541085B631EF276DAF85EEC61C2340461A3B37BAE9C26BE7291BE621CC",
+                "PublishUrlSubPath": "68656-20201030-04/4A413B1991AD089C3E1A2E921A4BB24F4DA1051695EABA03163E7A8A93B7A29F/dotnet-counters",
+                "AkaMsLink": "https://aka.ms/dotnet-counters/linux-arm"
+            }
+        ],
+        "ToolBundleAssets": [
+            {
+                "ToolName": "dotnet-counters",
+                "Rid": "linux-arm",
+                "PublishRelativePath": "ToolBundleAssets\\linux-arm\\dotnet-counters",
+                "PublishedPath": "\\\\random\\share\\68656-20201030-04\\ToolBundleAssets\\linux-arm\\dotnet-counters"
+            }
+        ],
+        "NugetAssets": [
+            {
+                "PublishRelativePath": "NugetAssets\\dotnet-counters.5.0.152202.nupkg",
+                "PublishedPath": "\\\\random\\share\\68656-20201030-04\\ToolBundleAssets\\linux-arm\\dotnet-counters"
+            }
+        ]
+    }
+    ```
diff --git a/eng/release/DiagnosticsReleaseTool/logging.json b/eng/release/DiagnosticsReleaseTool/logging.json
new file mode 100644 (file)
index 0000000..1a6c578
--- /dev/null
@@ -0,0 +1,15 @@
+{
+    "Logging": {
+      "LogLevel": {
+        "Default": "Information",
+        "System": "None",
+        "Microsoft": "None"
+      },
+      "Console":
+      {
+        "IncludeScopes": "true",
+        "TimestampFormat": "[HH:mm:ss] ",
+        "LogToStandardErrorThreshold": "Warning",
+      }
+    }
+  }
\ No newline at end of file
diff --git a/eng/release/Directory.Build.props b/eng/release/Directory.Build.props
new file mode 100644 (file)
index 0000000..100d8a4
--- /dev/null
@@ -0,0 +1,3 @@
+<Project>
+    <Import Project="$(MSBuildThisFileDirectory)..\Directory.Build.props"/>
+</Project>
\ No newline at end of file
diff --git a/eng/release/Directory.Build.targets b/eng/release/Directory.Build.targets
new file mode 100644 (file)
index 0000000..66a32ff
--- /dev/null
@@ -0,0 +1,3 @@
+<Project>
+    <Import Project="Sdk.targets" Sdk="Microsoft.DotNet.Arcade.Sdk" />
+</Project>
\ No newline at end of file
diff --git a/eng/release/Scripts/AcquireBuild.ps1 b/eng/release/Scripts/AcquireBuild.ps1
new file mode 100644 (file)
index 0000000..3835432
--- /dev/null
@@ -0,0 +1,70 @@
+param(
+  [Parameter(Mandatory=$true)][int] $BarBuildId,
+  [Parameter(Mandatory=$true)][string] $ReleaseVersion,
+  [Parameter(Mandatory=$true)][string] $DownloadTargetPath,
+  [Parameter(Mandatory=$true)][string] $SasSuffixes,
+  [Parameter(Mandatory=$true)][string] $AzdoToken,
+  [Parameter(Mandatory=$true)][string] $MaestroToken,
+  [Parameter(Mandatory=$false)][string] $MaestroApiEndPoint = 'https://maestro-prod.westus2.cloudapp.azure.com',
+  [switch] $help,
+  [Parameter(ValueFromRemainingArguments=$true)][String[]]$properties
+)
+function Write-Help() {
+    Write-Host "Common settings:"
+    Write-Host "  -BarBuildId <value>               BAR Build ID of the diagnostics build to publish."
+    Write-Host "  -ReleaseVersion <value>           Name to give the diagnostics release."
+    Write-Host "  -DownloadTargetPath <value>       Path to download the build to."
+    Write-Host "  -SasSuffixes <value>              Comma separated list of potential uri suffixes that can be used if anonymous access to a blob uri fails. Appended directly to the end of the URI. Use full SAS syntax with ?."
+    Write-Host "  -AzdoToken <value>                Azure DevOps token to use for builds queries"
+    Write-Host "  -MaestroToken <value>             Maestro token to use for querying BAR"
+    Write-Host "  -MaestroApiEndPoint <value>       BAR endpoint to use for build queries."
+    Write-Host ""
+}
+
+$ErrorActionPreference = 'Stop'
+Set-StrictMode -Version 2.0
+
+if ($help -or (($null -ne $properties) -and ($properties.Contains('/help') -or $properties.Contains('/?')))) {
+    Write-Help
+    exit 1
+}
+
+if ($null -ne $properties) {
+    Write-Host "Unexpected extra parameters: $properties."
+    exit 1
+}
+
+try {
+    $ci = $true
+
+    $darc = $null
+    try {
+        $darc = (Get-Command darc).Source
+    }
+    catch{
+        . $PSScriptRoot\..\..\common\tools.ps1
+        $darc = Get-Darc
+    }
+
+    & $darc gather-drop `
+        --id $BarBuildId `
+        --release-name $ReleaseVersion `
+        --output-dir $DownloadTargetPath `
+        --overwrite `
+        --sas-suffixes $SasSuffixes `
+        --azdev-pat $AzdoToken `
+        --bar-uri $MaestroApiEndPoint `
+        --password $MaestroToken `
+        --verbose
+
+    if ($LastExitCode -ne 0) {
+        Write-Host "Error: unable to gather the assets from build $BarBuildId to $DownloadTargetPath using darc."
+        Write-Host $_
+        exit 1
+    }
+
+    Write-Host 'done.'
+}
+catch {
+    Write-Host $_
+}
\ No newline at end of file
diff --git a/eng/release/Scripts/GenerateGithubRelease.ps1 b/eng/release/Scripts/GenerateGithubRelease.ps1
new file mode 100644 (file)
index 0000000..49c24b1
--- /dev/null
@@ -0,0 +1,188 @@
+param(
+  [Parameter(Mandatory=$true)][string] $ManifestPath,
+  [Parameter(Mandatory=$false)][string] $ReleaseNotes,
+  [Parameter(Mandatory=$true)][string] $GhOrganization,
+  [Parameter(Mandatory=$true)][string] $GhRepository,
+  [Parameter(Mandatory=$false)][string] $GhCliLink = "https://github.com/cli/cli/releases/download/v1.2.0/gh_1.2.0_windows_amd64.zip",
+  [Parameter(Mandatory=$true)][string] $TagName,
+  [bool] $DraftRelease = $false,
+  [switch] $help,
+  [Parameter(ValueFromRemainingArguments=$true)][String[]]$properties
+)
+function Write-Help() {
+    Write-Host "Publish release to GitHub. Expects an environtment variable GITHUB_TOKEN to perform auth."
+    Write-Host "Common settings:"
+    Write-Host "  -ManifestPath <value>       Path to a publishing manifest."
+    Write-Host "  -ReleaseNotes <value>       Path to release notes."
+    Write-Host "  -GhOrganization <value>     GitHub organization the repository lives in."
+    Write-Host "  -GhRepository <value>       GitHub repository in the organization to create the release on."
+    Write-Host "  -GhCliLink <value>          GitHub CLI download link."
+    Write-Host "  -TagName <value>            Tag to use for the release."
+    Write-Host "  -DraftRelease               Stage the release, but don't make it public yet."
+    Write-Host ""
+}
+function Get-ReleaseNotes()
+{
+    if ($ReleaseNotes)
+    {
+        if (!(Test-Path $ReleaseNotes))
+        {
+            Write-Error "Error: unable to find notes at $ReleaseNotes."
+            exit 1
+        }
+
+        return Get-Content -Raw -Path $ReleaseNotes
+    }
+}
+
+function Get-DownloadLinksAndChecksums($manifest)
+{
+
+    $linkTable = "<details>`n"
+    $linkTable += "<summary>Packages released to NuGet</summary>`n`n"
+
+    foreach ($nugetPackage in $manifest.NugetAssets)
+    {
+        $packageName = Split-Path $nugetPackage.PublishRelativePath -Leaf
+        $linkTable += "- ``" + $packageName  + "```n"
+    }
+
+    $linkTable += "</details>`n`n"
+
+    $filePublishData = @{}
+    $manifest.PublishInstructions | %{ $filePublishData.Add($_.FilePath, $_) }
+
+    $sortedTools = $manifest.ToolBundleAssets | Sort-Object -Property @{ Expression = "Rid" }, @{ Expression = "ToolName" }
+
+    $linkTable += "<details>`n"
+    $linkTable += "<summary>Global Tools - Single File Links</summary>`n`n"
+    $linkTable += "*Note*: All Windows assets are signed with a trusted Microsoft Authenticode Certificate. To verify `
+        integrity for Linux and macOS assets check the CSV in the assets section of the release for their SHA512 hashes.`n"
+    $linkTable += "| Tool | Platform | Download Link |`n"
+    $linkTable += "|:---:|:---:|:---:|`n"
+
+    $checksumCsv = "`"ToolName`",`"Rid`",`"DownloadLink`",`"Sha512`"`n"
+
+    foreach ($toolBundle in $sortedTools)
+    {
+        $hash = $filePublishData[$toolBundle.PublishedPath].Sha512
+        $name = $toolBundle.ToolName
+        $rid = $toolBundle.Rid
+
+        $link = "https://download.visualstudio.microsoft.com/download/pr/" + $filePublishData[$toolBundle.PublishedPath].PublishUrlSubPath
+        $linkTable += "| $name | $rid | [Download]($link) |`n";
+
+        $checksumCsv += "`"$name`",`"$rid`",`"$link`",`"$hash`"`n"
+    }
+
+    $linkTable += "</details>`n"
+    return $linkTable, $checksumCsv
+}
+
+function Post-GithubRelease($manifest, [string]$releaseBody, [string]$checksumCsvBody)
+{
+    $extractionPath = New-TemporaryFile | % { Remove-Item $_; New-Item -ItemType Directory -Path $_ }
+    $zipPath = Join-Path $extractionPath "ghcli.zip"
+    $ghTool = [IO.Path]::Combine($extractionPath, "bin", "gh.exe")
+
+    Write-Host "Downloading GitHub CLI from $GhCliLink."
+    try
+    {
+        $progressPreference = 'silentlyContinue'
+        Invoke-WebRequest $GhCliLink -OutFile $zipPath
+        Expand-Archive -Path $zipPath -DestinationPath $extractionPath
+        $progressPreference = 'Continue'
+    }
+    catch 
+    {
+        Write-Error "Unable to get GitHub CLI for release"
+        exit 1
+    }
+
+    if (!(Test-Path $ghTool))
+    {
+        Write-Error "Error: unable to find GitHub tool at expected location."
+        exit 1
+    }
+
+    if (!(Test-Path env:GITHUB_TOKEN))
+    {
+        Write-Error "Error: unable to find GitHub PAT. Please set in GITHUB_TOKEN."
+        exit 1
+    }
+
+    $extraParameters = @()
+
+    if ($DraftRelease -eq $true)
+    {
+        $extraParameters += '-d'
+    }
+
+    $releaseNotes = "release_notes.md"
+    $csvManifest = "checksums.csv"
+
+    Set-Content -Path $releaseNotes -Value $releaseBody
+    Set-Content -Path $csvManifest -Value $checksumCsvBody
+
+    if (-Not (Test-Path $releaseNotes)) {
+        Write-Error "Unable to find release notes"
+    }
+
+    if (-Not (Test-Path $csvManifest)) {
+        Write-Error "Unable to find release notes"
+    }
+
+    $releaseNotes = $(Get-ChildItem $releaseNotes).FullName
+    $csvManifest = $(Get-ChildItem $csvManifest).FullName
+    & $ghTool release create $TagName `
+        "`"$csvManifest#File Links And Checksums CSV`"" `
+        --repo "`"$GhOrganization/$GhRepository`"" `
+        --title "`"Diagnostics Release - $TagName`"" `
+        --notes-file "`"$releaseNotes`"" `
+        --target $manifest.Commit `
+        ($extraParameters -join ' ')
+
+    $exitCode = $LASTEXITCODE
+    if ($exitCode -ne 0) {
+        Write-Error "Something failed in creating the release."
+        exit 1
+    }
+}
+
+$ErrorActionPreference = 'Stop'
+Set-StrictMode -Version 2.0
+
+if ($help -or (($null -ne $properties) -and ($properties.Contains('/help') -or $properties.Contains('/?')))) {
+    Write-Help
+    exit 1
+}
+
+if ($null -ne $properties) {
+    Write-Error "Unexpected extra parameters: $properties."
+    exit 1
+}
+
+if (!(Test-Path $ManifestPath))
+{
+    Write-Error "Error: unable to find maifest at $ManifestPath."
+    exit 1
+}
+
+$manifestSize = $(Get-ChildItem $ManifestPath).length / 1kb
+
+# Limit size. For large manifests
+if ($manifestSize -gt 500)
+{
+    Write-Error "Error: Manifest $ManifestPath too large."
+    exit 1
+}
+
+$manifestJson = Get-Content -Raw -Path $ManifestPath | ConvertFrom-Json
+$linkCollection, $checksumCsvContent = Get-DownloadLinksAndChecksums $manifestJson
+
+$releaseNotesText = Get-ReleaseNotes
+$releaseNotesText += "`n`n" + $linkCollection
+
+Post-GithubRelease -manifest $manifestJson `
+                -releaseBody $releaseNotesText `
+                -checksumCsvBody $checksumCsvContent
diff --git a/eng/release/Scripts/PublishToNuget.ps1 b/eng/release/Scripts/PublishToNuget.ps1
new file mode 100644 (file)
index 0000000..2260643
--- /dev/null
@@ -0,0 +1,79 @@
+param(
+  [Parameter(Mandatory=$true)][string] $ManifestPath,
+  [Parameter(Mandatory=$true)][string] $StagingPath,
+  [Parameter(Mandatory=$true)][string] $FeedEndpoint,
+  [Parameter(Mandatory=$true)][string] $FeedPat,
+  [switch] $help,
+  [Parameter(ValueFromRemainingArguments=$true)][String[]]$properties
+)
+function Write-Help() {
+    Write-Host "Publish packages specified in a manifest. This should not be used for large manifests."
+    Write-Host "Common settings:"
+    Write-Host "  -ManifestPath <value>      Path to a publishing manifest where the NuGet packages to publish can be found."
+    Write-Host "  -StagingPath <value>       Path where the assets in the manifests are laid out."
+    Write-Host "  -FeedEndpoint <value>      NuGet feed to publish the packages to."
+    Write-Host "  -FeedPat <value>           PAT to use in the publish process."
+    Write-Host ""
+}
+
+$ErrorActionPreference = 'Stop'
+Set-StrictMode -Version 2.0
+
+if ($help -or (($null -ne $properties) -and ($properties.Contains('/help') -or $properties.Contains('/?')))) {
+    Write-Help
+    exit 1
+}
+
+if ($null -ne $properties) {
+    Write-Error "Unexpected extra parameters: $properties."
+    exit 1
+}
+
+if (!(Test-Path $ManifestPath))
+{
+    Write-Error "Error: unable to find maifest at $ManifestPath."
+    exit 1
+}
+
+$manifestSize = $(Get-ChildItem $ManifestPath).length / 1kb
+
+# Limit size. For large manifests
+if ($manifestSize -gt 500) 
+{
+    Write-Error "Error: Manifest $ManifestPath too large."
+    exit 1
+}
+
+$manifestJson = Get-Content -Raw -Path $ManifestPath | ConvertFrom-Json
+
+$failedToPublish = 0
+foreach ($nugetPack in $manifestJson.NugetAssets)
+{
+    $packagePath = Join-Path $StagingPath $nugetPack.PublishRelativePath
+    try
+    {
+        Write-Host "Downloading: $nugetPack."
+        $progressPreference = 'silentlyContinue'
+        Invoke-WebRequest -Uri $nugetPack.PublishedPath -OutFile (New-Item -Path $packagePath -Force)
+        $progressPreference = 'Continue'
+
+        Write-Host "Publishing $packagePath."
+        & "$PSScriptRoot/../../../dotnet.cmd" nuget push $packagePath --source $FeedEndpoint --api-key $FeedPat
+        if ($LastExitCode -ne 0)
+        {
+            Write-Error "Error: unable to publish $($nugetPack.PublishRelativePath)."
+            $failedToPublish++
+        }
+    }
+    catch
+    {
+        Write-Error "Error: unable to publish $($nugetPack.PublishRelativePath)."
+        $failedToPublish++
+    }
+}
+
+if ($failedToPublish -ne 0)
+{
+    Write-Error "Error: $failedToPublish packages unpublished."
+    exit 1
+}
diff --git a/eng/release/tool-list.json b/eng/release/tool-list.json
new file mode 100644 (file)
index 0000000..f8bdb7c
--- /dev/null
@@ -0,0 +1,37 @@
+{
+  "ToolBundleAssets": {
+    "AkaMsSchema": "https://aka.ms/<FileNameNoExt>/<Rid>",
+    "AssetList": [
+      {
+        "name": "dotnet-counters",
+        "rids": ["win-x64", "win-x86", "win-arm", "win-arm64", "linux-x64", "linux-musl-arm64", "osx-x64", "linux-arm64", "linux-musl-x64", "linux-arm"]
+      },
+      {
+        "name": "dotnet-dump",
+        "rids": ["win-x64", "win-x86", "win-arm", "win-arm64", "linux-x64", "linux-musl-arm64", "osx-x64", "linux-arm64", "linux-musl-x64", "linux-arm"]
+      },
+      {
+        "name": "dotnet-gcdump",
+        "rids": ["win-x64", "win-x86", "win-arm", "win-arm64", "linux-x64", "linux-musl-arm64", "osx-x64", "linux-arm64", "linux-musl-x64", "linux-arm"]
+      },
+      {
+        "name": "dotnet-sos",
+        "rids": ["win-x64", "win-x86", "win-arm", "win-arm64", "linux-x64", "linux-musl-arm64", "osx-x64", "linux-arm64", "linux-musl-x64", "linux-arm"]
+      },
+      {
+        "name": "dotnet-trace",
+        "rids": ["win-x64", "win-x86", "win-arm", "win-arm64", "linux-x64", "linux-musl-arm64", "osx-x64", "linux-arm64", "linux-musl-x64", "linux-arm"]
+      }
+    ]
+  },
+  "NugetAssets": {
+    "AssetList":[
+      "dotnet-counters",
+      "dotnet-dump",
+      "dotnet-gcdump",
+      "dotnet-sos",
+      "dotnet-trace",
+      "Microsoft.Diagnostics.NETCore.Client"
+    ]
+  }
+}
\ No newline at end of file