diff --git a/src/Build/Logging/CICDLogger/AzureDevOps/AzureDevOpsForwardingLogger.cs b/src/Build/Logging/CICDLogger/AzureDevOps/AzureDevOpsForwardingLogger.cs
new file mode 100644
index 00000000000..dce9ce365dd
--- /dev/null
+++ b/src/Build/Logging/CICDLogger/AzureDevOps/AzureDevOpsForwardingLogger.cs
@@ -0,0 +1,76 @@
+// 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;
+
+#nullable enable
+
+namespace Microsoft.Build.Logging.CICDLogger.AzureDevOps;
+
+///
+/// Forwarding logger for Azure DevOps that filters and forwards events from build nodes to the central logger.
+///
+public sealed class AzureDevOpsForwardingLogger : IForwardingLogger
+{
+ ///
+ public IEventRedirector? BuildEventRedirector { get; set; }
+
+ ///
+ public int NodeId { get; set; }
+
+ ///
+ public LoggerVerbosity Verbosity { get; set; } = LoggerVerbosity.Normal;
+
+ ///
+ public string? Parameters { get; set; }
+
+ ///
+ public void Initialize(IEventSource eventSource, int nodeCount)
+ {
+ NodeId = nodeCount;
+ Initialize(eventSource);
+ }
+
+ ///
+ public void Initialize(IEventSource eventSource)
+ {
+ // Forward all errors and warnings unconditionally
+ eventSource.ErrorRaised += ForwardEvent;
+ eventSource.WarningRaised += ForwardEvent;
+
+ // Forward build lifecycle events
+ eventSource.BuildStarted += ForwardEvent;
+ eventSource.BuildFinished += ForwardEvent;
+ eventSource.ProjectStarted += ForwardEvent;
+ eventSource.ProjectFinished += ForwardEvent;
+
+ // Forward messages based on importance and verbosity
+ eventSource.MessageRaised += MessageRaised;
+ }
+
+ ///
+ public void Shutdown()
+ {
+ }
+
+ private void ForwardEvent(object sender, BuildEventArgs e)
+ {
+ BuildEventRedirector?.ForwardEvent(e);
+ }
+
+ private void MessageRaised(object sender, BuildMessageEventArgs e)
+ {
+ // Forward messages based on verbosity
+ if (Verbosity == LoggerVerbosity.Quiet)
+ {
+ return;
+ }
+
+ if (e.Importance == MessageImportance.High ||
+ (e.Importance == MessageImportance.Normal && Verbosity >= LoggerVerbosity.Normal) ||
+ (e.Importance == MessageImportance.Low && Verbosity >= LoggerVerbosity.Detailed))
+ {
+ BuildEventRedirector?.ForwardEvent(e);
+ }
+ }
+}
diff --git a/src/Build/Logging/CICDLogger/AzureDevOps/AzureDevOpsLogger.cs b/src/Build/Logging/CICDLogger/AzureDevOps/AzureDevOpsLogger.cs
new file mode 100644
index 00000000000..a2eece43cb9
--- /dev/null
+++ b/src/Build/Logging/CICDLogger/AzureDevOps/AzureDevOpsLogger.cs
@@ -0,0 +1,354 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using Microsoft.Build.Framework;
+
+#nullable enable
+
+namespace Microsoft.Build.Logging.CICDLogger.AzureDevOps;
+
+///
+/// Data captured from project evaluation.
+///
+public sealed class AzureDevOpsEvalData
+{
+ public string? TargetFramework { get; set; }
+ public string? RuntimeIdentifier { get; set; }
+}
+
+///
+/// Wrapper for build events with timestamps to maintain ordering.
+///
+public sealed class TimestampedBuildEvent
+{
+ public DateTime Timestamp { get; }
+ public BuildEventArgs Event { get; }
+
+ public TimestampedBuildEvent(BuildEventArgs evt)
+ {
+ Event = evt;
+ Timestamp = evt.Timestamp;
+ }
+}
+
+///
+/// Data stored for each project during the build.
+///
+public sealed class AzureDevOpsProjectData
+{
+ public string? ProjectFile { get; set; }
+ public string? TargetFramework { get; set; }
+ public string? RuntimeIdentifier { get; set; }
+ public List Events { get; } = new();
+
+ public int ErrorCount => Events.Count(e => e.Event is BuildErrorEventArgs);
+ public int WarningCount => Events.Count(e => e.Event is BuildWarningEventArgs);
+}
+
+///
+/// Data stored for the entire build session.
+///
+public sealed class AzureDevOpsBuildData
+{
+ public int TotalErrors { get; set; }
+ public int TotalWarnings { get; set; }
+}
+
+///
+/// Logger for Azure DevOps that formats build diagnostics using Azure Pipelines logging commands.
+/// See: https://learn.microsoft.com/en-us/azure/devops/pipelines/scripts/logging-commands
+///
+public sealed class AzureDevOpsLogger : ProjectTrackingLoggerBase
+{
+ private Action _write = Console.Out.Write;
+
+ ///
+ public override LoggerVerbosity Verbosity { get; set; } = LoggerVerbosity.Normal;
+
+ ///
+ public override string? Parameters { get; set; }
+
+ ///
+ /// Detects if Azure DevOps environment is active.
+ ///
+ /// true if running in Azure DevOps; otherwise, false.
+ public static bool IsEnabled()
+ {
+ return !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("TF_BUILD"));
+ }
+
+ ///
+ public override void Shutdown()
+ {
+ }
+
+ #region Abstract method implementations
+
+ protected override AzureDevOpsEvalData CreateEvalData(ProjectEvaluationFinishedEventArgs e)
+ {
+ var evalData = new AzureDevOpsEvalData();
+
+ // Extract target framework and runtime identifier from evaluation properties
+ if (e.Properties != null)
+ {
+ foreach (var item in e.Properties)
+ {
+ if (item is System.Collections.DictionaryEntry kvp)
+ {
+ var key = kvp.Key as string;
+ if (key == "TargetFramework")
+ {
+ evalData.TargetFramework = kvp.Value?.ToString();
+ }
+ else if (key == "RuntimeIdentifier")
+ {
+ evalData.RuntimeIdentifier = kvp.Value?.ToString();
+ }
+ }
+ }
+ }
+
+ return evalData;
+ }
+
+ protected override AzureDevOpsProjectData? CreateProjectData(AzureDevOpsEvalData evalData, ProjectStartedEventArgs e)
+ {
+ return new AzureDevOpsProjectData
+ {
+ ProjectFile = e.ProjectFile,
+ TargetFramework = evalData?.TargetFramework,
+ RuntimeIdentifier = evalData?.RuntimeIdentifier
+ };
+ }
+
+ protected override AzureDevOpsBuildData CreateBuildData(BuildStartedEventArgs e)
+ {
+ return new AzureDevOpsBuildData();
+ }
+
+ #endregion
+
+ #region Event handlers
+
+ protected override void OnErrorRaised(BuildErrorEventArgs e, AzureDevOpsProjectData? projectData, AzureDevOpsBuildData buildData)
+ {
+ buildData.TotalErrors++;
+
+ if (projectData != null)
+ {
+ // Buffer error for output at project finished
+ projectData.Events.Add(new TimestampedBuildEvent(e));
+ }
+ else
+ {
+ // No project context, write immediately
+ WriteDiagnostic("error", e.File, e.LineNumber, e.ColumnNumber, e.Code, e.Message);
+ }
+ }
+
+ protected override void OnWarningRaised(BuildWarningEventArgs e, AzureDevOpsProjectData? projectData, AzureDevOpsBuildData buildData)
+ {
+ buildData.TotalWarnings++;
+
+ if (projectData != null)
+ {
+ // Buffer warning for output at project finished
+ projectData.Events.Add(new TimestampedBuildEvent(e));
+ }
+ else
+ {
+ // No project context, write immediately
+ WriteDiagnostic("warning", e.File, e.LineNumber, e.ColumnNumber, e.Code, e.Message);
+ }
+ }
+
+ protected override void OnMessageRaised(BuildMessageEventArgs e, AzureDevOpsProjectData? projectData, AzureDevOpsBuildData buildData)
+ {
+ if (Verbosity == LoggerVerbosity.Quiet)
+ {
+ return;
+ }
+
+ // First question: should I care about this message?
+ bool shouldLog = e.Importance == MessageImportance.High ||
+ (e.Importance == MessageImportance.Normal && Verbosity >= LoggerVerbosity.Normal) ||
+ (e.Importance == MessageImportance.Low && Verbosity >= LoggerVerbosity.Detailed);
+
+ if (!shouldLog)
+ {
+ return;
+ }
+
+ // Second question: do I log it now or at the end of the project build?
+ if (projectData != null)
+ {
+ // Buffer for output at project finished
+ projectData.Events.Add(new TimestampedBuildEvent(e));
+ }
+ else
+ {
+ // No project context, write immediately
+ _write(e.Message ?? string.Empty);
+ _write(Environment.NewLine);
+ }
+ }
+
+ protected override void OnProjectFinished(ProjectFinishedEventArgs e, AzureDevOpsProjectData projectData, AzureDevOpsBuildData buildData)
+ {
+ if (Verbosity >= LoggerVerbosity.Normal)
+ {
+ // Build project header with context information
+ var header = new StringBuilder();
+ header.Append("Building ");
+ header.Append(projectData.ProjectFile ?? e.ProjectFile ?? "project");
+
+ if (!string.IsNullOrEmpty(projectData.TargetFramework))
+ {
+ header.Append(" (");
+ header.Append(projectData.TargetFramework);
+
+ if (!string.IsNullOrEmpty(projectData.RuntimeIdentifier))
+ {
+ header.Append(" | ");
+ header.Append(projectData.RuntimeIdentifier);
+ }
+
+ header.Append(')');
+ }
+
+ // Add success/failure status
+ if (!e.Succeeded)
+ {
+ header.Append(" - Failed");
+ }
+ else if (projectData.ErrorCount > 0 || projectData.WarningCount > 0)
+ {
+ header.Append($" - {projectData.ErrorCount} error(s), {projectData.WarningCount} warning(s)");
+ }
+
+ // Output group header (collapsible)
+ _write($"##[group]{header}");
+ _write(Environment.NewLine);
+
+ // Output all events in timestamp order
+ foreach (var timestampedEvent in projectData.Events.OrderBy(e => e.Timestamp))
+ {
+ switch (timestampedEvent.Event)
+ {
+ case BuildErrorEventArgs error:
+ WriteDiagnostic("error", error.File, error.LineNumber, error.ColumnNumber, error.Code, error.Message);
+ break;
+ case BuildWarningEventArgs warning:
+ WriteDiagnostic("warning", warning.File, warning.LineNumber, warning.ColumnNumber, warning.Code, warning.Message);
+ break;
+ case BuildMessageEventArgs message:
+ _write(message.Message ?? string.Empty);
+ _write(Environment.NewLine);
+ break;
+ }
+ }
+
+ // End the group
+ _write("##[endgroup]");
+ _write(Environment.NewLine);
+ }
+ }
+
+ protected override void OnBuildFinished(BuildFinishedEventArgs e, AzureDevOpsProjectData[] projectData, AzureDevOpsBuildData buildData)
+ {
+ if (Verbosity >= LoggerVerbosity.Minimal)
+ {
+ if (!e.Succeeded)
+ {
+ _write($"##vso[task.complete result=Failed]Build failed. {buildData.TotalErrors} error(s), {buildData.TotalWarnings} warning(s)");
+ }
+ else
+ {
+ _write($"Build succeeded. {buildData.TotalWarnings} warning(s)");
+ }
+ _write(Environment.NewLine);
+ }
+ }
+
+ #endregion
+
+ #region Helper methods
+
+ ///
+ /// Writes a diagnostic (error or warning) using Azure DevOps logging commands.
+ ///
+ private void WriteDiagnostic(string type, string? file, int lineNumber, int columnNumber, string? code, string? message)
+ {
+ // Format: ##vso[task.logissue type={type};sourcepath=file;linenumber=line;columnnumber=col;code=code;]message
+ var output = new StringBuilder();
+ output.Append("##vso[task.logissue type=");
+ output.Append(type);
+
+ if (!string.IsNullOrEmpty(file))
+ {
+ output.Append(";sourcepath=");
+ output.Append(EscapeProperty(file!));
+
+ if (lineNumber > 0)
+ {
+ output.Append(";linenumber=");
+ output.Append(lineNumber);
+
+ if (columnNumber > 0)
+ {
+ output.Append(";columnnumber=");
+ output.Append(columnNumber);
+ }
+ }
+ }
+
+ if (!string.IsNullOrEmpty(code))
+ {
+ output.Append(";code=");
+ output.Append(EscapeProperty(code!));
+ }
+
+ output.Append(']');
+ output.Append(EscapeData(message ?? string.Empty));
+ output.AppendLine();
+
+ _write(output.ToString());
+ }
+
+ ///
+ /// Escapes special characters in property values for Azure DevOps logging commands.
+ ///
+ private static string EscapeProperty(string value)
+ {
+ if (string.IsNullOrEmpty(value))
+ {
+ return value;
+ }
+
+ return value.Replace(";", "%3B")
+ .Replace("\r", "%0D")
+ .Replace("\n", "%0A")
+ .Replace("]", "%5D");
+ }
+
+ ///
+ /// Escapes special characters in message data for Azure DevOps logging commands.
+ ///
+ private static string EscapeData(string value)
+ {
+ if (string.IsNullOrEmpty(value))
+ {
+ return value;
+ }
+
+ return value.Replace("\r", "%0D")
+ .Replace("\n", "%0A")
+ .Replace("]", "%5D");
+ }
+
+ #endregion
+}
diff --git a/src/Build/Logging/CICDLogger/GitHubActions/GitHubActionsForwardingLogger.cs b/src/Build/Logging/CICDLogger/GitHubActions/GitHubActionsForwardingLogger.cs
new file mode 100644
index 00000000000..4c625476662
--- /dev/null
+++ b/src/Build/Logging/CICDLogger/GitHubActions/GitHubActionsForwardingLogger.cs
@@ -0,0 +1,76 @@
+// 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;
+
+#nullable enable
+
+namespace Microsoft.Build.Logging.CICDLogger.GitHubActions;
+
+///
+/// Forwarding logger for GitHub Actions that filters and forwards events from build nodes to the central logger.
+///
+public sealed class GitHubActionsForwardingLogger : IForwardingLogger
+{
+ ///
+ public IEventRedirector? BuildEventRedirector { get; set; }
+
+ ///
+ public int NodeId { get; set; }
+
+ ///
+ public LoggerVerbosity Verbosity { get; set; } = LoggerVerbosity.Normal;
+
+ ///
+ public string? Parameters { get; set; }
+
+ ///
+ public void Initialize(IEventSource eventSource, int nodeCount)
+ {
+ NodeId = nodeCount;
+ Initialize(eventSource);
+ }
+
+ ///
+ public void Initialize(IEventSource eventSource)
+ {
+ // Forward all errors and warnings unconditionally
+ eventSource.ErrorRaised += ForwardEvent;
+ eventSource.WarningRaised += ForwardEvent;
+
+ // Forward build lifecycle events
+ eventSource.BuildStarted += ForwardEvent;
+ eventSource.BuildFinished += ForwardEvent;
+ eventSource.ProjectStarted += ForwardEvent;
+ eventSource.ProjectFinished += ForwardEvent;
+
+ // Forward messages based on importance and verbosity
+ eventSource.MessageRaised += MessageRaised;
+ }
+
+ ///
+ public void Shutdown()
+ {
+ }
+
+ private void ForwardEvent(object sender, BuildEventArgs e)
+ {
+ BuildEventRedirector?.ForwardEvent(e);
+ }
+
+ private void MessageRaised(object sender, BuildMessageEventArgs e)
+ {
+ // Forward messages based on verbosity
+ if (Verbosity == LoggerVerbosity.Quiet)
+ {
+ return;
+ }
+
+ if (e.Importance == MessageImportance.High ||
+ (e.Importance == MessageImportance.Normal && Verbosity >= LoggerVerbosity.Normal) ||
+ (e.Importance == MessageImportance.Low && Verbosity >= LoggerVerbosity.Detailed))
+ {
+ BuildEventRedirector?.ForwardEvent(e);
+ }
+ }
+}
diff --git a/src/Build/Logging/CICDLogger/GitHubActions/GitHubActionsLogger.cs b/src/Build/Logging/CICDLogger/GitHubActions/GitHubActionsLogger.cs
new file mode 100644
index 00000000000..05d35064a21
--- /dev/null
+++ b/src/Build/Logging/CICDLogger/GitHubActions/GitHubActionsLogger.cs
@@ -0,0 +1,524 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using Microsoft.Build.Framework;
+
+#nullable enable
+
+namespace Microsoft.Build.Logging.CICDLogger.GitHubActions;
+
+///
+/// Data captured from project evaluation.
+///
+public sealed class GitHubActionsEvalData
+{
+ public string? TargetFramework { get; set; }
+ public string? RuntimeIdentifier { get; set; }
+}
+
+///
+/// Wrapper for build events with timestamps to maintain ordering.
+///
+public sealed class TimestampedBuildEvent
+{
+ public DateTime Timestamp { get; }
+ public BuildEventArgs Event { get; }
+
+ public TimestampedBuildEvent(BuildEventArgs evt)
+ {
+ Event = evt;
+ Timestamp = evt.Timestamp;
+ }
+}
+
+///
+/// Data stored for each project during the build.
+///
+public sealed class GitHubActionsProjectData
+{
+ public string? ProjectFile { get; set; }
+ public string? TargetFramework { get; set; }
+ public string? RuntimeIdentifier { get; set; }
+ public List Events { get; } = new();
+
+ public int ErrorCount => Events.Count(e => e.Event is BuildErrorEventArgs);
+ public int WarningCount => Events.Count(e => e.Event is BuildWarningEventArgs);
+}
+
+///
+/// Data stored for the entire build session.
+///
+public sealed class GitHubActionsBuildData
+{
+ public int TotalErrors { get; set; }
+ public int TotalWarnings { get; set; }
+}
+
+///
+/// Logger for GitHub Actions that formats build diagnostics using GitHub Actions workflow commands.
+/// See: https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions
+///
+public sealed class GitHubActionsLogger : ProjectTrackingLoggerBase
+{
+ private Action _write = Console.Out.Write;
+
+ ///
+ public override LoggerVerbosity Verbosity { get; set; } = LoggerVerbosity.Normal;
+
+ ///
+ public override string? Parameters { get; set; }
+
+ ///
+ public override void Initialize(IEventSource eventSource, int nodeCount)
+ {
+ // Check for ACTIONS_STEP_DEBUG to force diagnostic verbosity
+ if (Traits.IsEnvVarOneOrTrue("ACTIONS_STEP_DEBUG"))
+ {
+ Verbosity = LoggerVerbosity.Diagnostic;
+ }
+
+ base.Initialize(eventSource, nodeCount);
+ }
+
+ ///
+ /// Detects if GitHub Actions environment is active.
+ ///
+ /// true if running in GitHub Actions; otherwise, false.
+ public static bool IsEnabled()
+ {
+ return Traits.IsEnvVarOneOrTrue("GITHUB_ACTIONS");
+ }
+
+ ///
+ public override void Shutdown()
+ {
+ }
+
+ #region Abstract method implementations
+
+ protected override GitHubActionsEvalData CreateEvalData(ProjectEvaluationFinishedEventArgs e)
+ {
+ var evalData = new GitHubActionsEvalData();
+
+ // Extract target framework and runtime identifier from evaluation properties
+ if (e.Properties != null)
+ {
+ foreach (var item in e.Properties)
+ {
+ if (item is System.Collections.DictionaryEntry kvp)
+ {
+ var key = kvp.Key as string;
+ if (key == "TargetFramework")
+ {
+ evalData.TargetFramework = kvp.Value?.ToString();
+ }
+ else if (key == "RuntimeIdentifier")
+ {
+ evalData.RuntimeIdentifier = kvp.Value?.ToString();
+ }
+ }
+ }
+ }
+
+ return evalData;
+ }
+
+ protected override GitHubActionsProjectData? CreateProjectData(GitHubActionsEvalData evalData, ProjectStartedEventArgs e)
+ {
+ return new GitHubActionsProjectData
+ {
+ ProjectFile = e.ProjectFile,
+ TargetFramework = evalData?.TargetFramework,
+ RuntimeIdentifier = evalData?.RuntimeIdentifier
+ };
+ }
+
+ protected override GitHubActionsBuildData CreateBuildData(BuildStartedEventArgs e)
+ {
+ return new GitHubActionsBuildData();
+ }
+
+ #endregion
+
+ #region Event handlers
+
+ protected override void OnErrorRaised(BuildErrorEventArgs e, GitHubActionsProjectData? projectData, GitHubActionsBuildData buildData)
+ {
+ buildData.TotalErrors++;
+
+ if (projectData != null)
+ {
+ // Buffer error for output at project finished
+ projectData.Events.Add(new TimestampedBuildEvent(e));
+ }
+ else
+ {
+ // No project context, write immediately
+ WriteDiagnostic("error", e.File, e.LineNumber, e.ColumnNumber, e.EndColumnNumber, e.Code, e.Message);
+ }
+ }
+
+ protected override void OnWarningRaised(BuildWarningEventArgs e, GitHubActionsProjectData? projectData, GitHubActionsBuildData buildData)
+ {
+ buildData.TotalWarnings++;
+
+ if (projectData != null)
+ {
+ // Buffer warning for output at project finished
+ projectData.Events.Add(new TimestampedBuildEvent(e));
+ }
+ else
+ {
+ // No project context, write immediately
+ WriteDiagnostic("warning", e.File, e.LineNumber, e.ColumnNumber, e.EndColumnNumber, e.Code, e.Message);
+ }
+ }
+
+ protected override void OnMessageRaised(BuildMessageEventArgs e, GitHubActionsProjectData? projectData, GitHubActionsBuildData buildData)
+ {
+ if (Verbosity == LoggerVerbosity.Quiet)
+ {
+ return;
+ }
+
+ // First question: should I care about this message?
+ bool shouldLog = e.Importance == MessageImportance.High ||
+ (e.Importance == MessageImportance.Normal && Verbosity >= LoggerVerbosity.Normal) ||
+ (e.Importance == MessageImportance.Low && Verbosity >= LoggerVerbosity.Detailed);
+
+ if (!shouldLog)
+ {
+ return;
+ }
+
+ // Second question: do I log it now or at the end of the project build?
+ if (projectData != null)
+ {
+ // Buffer for output at project finished
+ projectData.Events.Add(new TimestampedBuildEvent(e));
+ }
+ else
+ {
+ // No project context, write immediately
+ _write(e.Message ?? string.Empty);
+ _write(Environment.NewLine);
+ }
+ }
+
+ protected override void OnProjectFinished(ProjectFinishedEventArgs e, GitHubActionsProjectData projectData, GitHubActionsBuildData buildData)
+ {
+ if (Verbosity >= LoggerVerbosity.Normal)
+ {
+ // Build project header with context information
+ var header = new StringBuilder();
+ header.Append("Building ");
+ header.Append(projectData.ProjectFile ?? e.ProjectFile ?? "project");
+
+ if (!string.IsNullOrEmpty(projectData.TargetFramework))
+ {
+ header.Append(" (");
+ header.Append(projectData.TargetFramework);
+
+ if (!string.IsNullOrEmpty(projectData.RuntimeIdentifier))
+ {
+ header.Append(" | ");
+ header.Append(projectData.RuntimeIdentifier);
+ }
+
+ header.Append(')');
+ }
+
+ // Add success/failure status
+ if (!e.Succeeded)
+ {
+ header.Append(" - Failed");
+ }
+ else if (projectData.ErrorCount > 0 || projectData.WarningCount > 0)
+ {
+ header.Append($" - {projectData.ErrorCount} error(s), {projectData.WarningCount} warning(s)");
+ }
+
+ // Use groups to collapse project output in GitHub Actions
+ _write($"::group::{header}");
+ _write(Environment.NewLine);
+
+ // Output all events in timestamp order
+ foreach (var timestampedEvent in projectData.Events.OrderBy(e => e.Timestamp))
+ {
+ switch (timestampedEvent.Event)
+ {
+ case BuildErrorEventArgs error:
+ WriteDiagnostic("error", error.File, error.LineNumber, error.ColumnNumber, error.EndColumnNumber, error.Code, error.Message);
+ break;
+ case BuildWarningEventArgs warning:
+ WriteDiagnostic("warning", warning.File, warning.LineNumber, warning.ColumnNumber, warning.EndColumnNumber, warning.Code, warning.Message);
+ break;
+ case BuildMessageEventArgs message:
+ _write(message.Message ?? string.Empty);
+ _write(Environment.NewLine);
+ break;
+ }
+ }
+
+ _write("::endgroup::");
+ _write(Environment.NewLine);
+ }
+ }
+
+ protected override void OnBuildFinished(BuildFinishedEventArgs e, GitHubActionsProjectData[] projectData, GitHubActionsBuildData buildData)
+ {
+ if (Verbosity >= LoggerVerbosity.Minimal)
+ {
+ if (!e.Succeeded)
+ {
+ _write($"Build failed. {buildData.TotalErrors} error(s), {buildData.TotalWarnings} warning(s)");
+ }
+ else
+ {
+ _write($"Build succeeded. {buildData.TotalWarnings} warning(s)");
+ }
+ _write(Environment.NewLine);
+ }
+
+ // Write build summary to GITHUB_STEP_SUMMARY file if available
+ WriteStepSummary(e, projectData, buildData);
+ }
+
+ #endregion
+
+ #region Helper methods
+
+ ///
+ /// Writes the build summary to the GitHub Step Summary file.
+ ///
+ private void WriteStepSummary(BuildFinishedEventArgs e, GitHubActionsProjectData[] projectData, GitHubActionsBuildData buildData)
+ {
+ var summaryFile = Environment.GetEnvironmentVariable("GITHUB_STEP_SUMMARY");
+ if (string.IsNullOrEmpty(summaryFile))
+ {
+ return;
+ }
+
+ try
+ {
+ using var writer = new System.IO.StreamWriter(summaryFile, append: true);
+
+ // Write header
+ writer.WriteLine("## Build Summary");
+ writer.WriteLine();
+
+ // Write overall status
+ if (e.Succeeded)
+ {
+ writer.WriteLine("✅ **Build Succeeded**");
+ }
+ else
+ {
+ writer.WriteLine("❌ **Build Failed**");
+ }
+ writer.WriteLine();
+
+ // Write summary table
+ writer.WriteLine($"- **Total Errors:** {buildData.TotalErrors}");
+ writer.WriteLine($"- **Total Warnings:** {buildData.TotalWarnings}");
+ writer.WriteLine();
+
+ // Write per-project details
+ if (projectData.Length > 0)
+ {
+ writer.WriteLine("### Project Details");
+ writer.WriteLine();
+
+ foreach (var project in projectData)
+ {
+ // Project header
+ writer.Write("#### ");
+ writer.Write(project.ProjectFile ?? "project");
+
+ if (!string.IsNullOrEmpty(project.TargetFramework))
+ {
+ writer.Write(" (");
+ writer.Write(project.TargetFramework);
+ if (!string.IsNullOrEmpty(project.RuntimeIdentifier))
+ {
+ writer.Write(" | ");
+ writer.Write(project.RuntimeIdentifier);
+ }
+ writer.Write(")");
+ }
+ writer.WriteLine();
+ writer.WriteLine();
+
+ if (project.ErrorCount > 0 || project.WarningCount > 0)
+ {
+ writer.WriteLine($"- Errors: {project.ErrorCount}");
+ writer.WriteLine($"- Warnings: {project.WarningCount}");
+ writer.WriteLine();
+
+ // Write diagnostics ordered by timestamp
+ if (project.Events.Count > 0)
+ {
+ writer.WriteLine("");
+ writer.WriteLine("View Diagnostics
");
+ writer.WriteLine();
+
+ foreach (var timestampedEvent in project.Events.OrderBy(e => e.Timestamp))
+ {
+ switch (timestampedEvent.Event)
+ {
+ case BuildErrorEventArgs error:
+ writer.Write("❌ **Error** ");
+ if (!string.IsNullOrEmpty(error.Code))
+ {
+ writer.Write($"`{error.Code}` ");
+ }
+ if (!string.IsNullOrEmpty(error.File))
+ {
+ writer.Write($"in `{error.File}`");
+ if (error.LineNumber > 0)
+ {
+ writer.Write($" (line {error.LineNumber}");
+ if (error.ColumnNumber > 0)
+ {
+ writer.Write($", col {error.ColumnNumber}");
+ }
+ writer.Write(")");
+ }
+ }
+ writer.WriteLine();
+ writer.WriteLine($" {error.Message}");
+ writer.WriteLine();
+ break;
+
+ case BuildWarningEventArgs warning:
+ writer.Write("⚠️ **Warning** ");
+ if (!string.IsNullOrEmpty(warning.Code))
+ {
+ writer.Write($"`{warning.Code}` ");
+ }
+ if (!string.IsNullOrEmpty(warning.File))
+ {
+ writer.Write($"in `{warning.File}`");
+ if (warning.LineNumber > 0)
+ {
+ writer.Write($" (line {warning.LineNumber}");
+ if (warning.ColumnNumber > 0)
+ {
+ writer.Write($", col {warning.ColumnNumber}");
+ }
+ writer.Write(")");
+ }
+ }
+ writer.WriteLine();
+ writer.WriteLine($" {warning.Message}");
+ writer.WriteLine();
+ break;
+ }
+ }
+
+ writer.WriteLine(" ");
+ writer.WriteLine();
+ }
+ }
+ else
+ {
+ writer.WriteLine("✅ No errors or warnings");
+ writer.WriteLine();
+ }
+ }
+ }
+
+ writer.WriteLine("---");
+ writer.WriteLine($"*Build completed at {DateTime.Now:yyyy-MM-dd HH:mm:ss}*");
+ }
+ catch
+ {
+ // Silently fail if we can't write to the summary file (permission issues, etc.)
+ }
+ }
+
+ ///
+ /// Writes a diagnostic (error or warning) using GitHub Actions workflow commands.
+ ///
+ private void WriteDiagnostic(string type, string? file, int lineNumber, int columnNumber, int endColumnNumber, string? code, string? message)
+ {
+ // Format: ::{type} file={name},line={line},col={col},endColumn={endCol},title={title}::{message}
+ var output = new StringBuilder();
+ output.Append("::");
+ output.Append(type);
+
+ if (!string.IsNullOrEmpty(file))
+ {
+ output.Append(" file=");
+ output.Append(EscapeProperty(file!));
+
+ if (lineNumber > 0)
+ {
+ output.Append(",line=");
+ output.Append(lineNumber);
+
+ if (columnNumber > 0)
+ {
+ output.Append(",col=");
+ output.Append(columnNumber);
+
+ if (endColumnNumber > 0)
+ {
+ output.Append(",endColumn=");
+ output.Append(endColumnNumber);
+ }
+ }
+ }
+ }
+
+ if (!string.IsNullOrEmpty(code))
+ {
+ output.Append(",title=");
+ output.Append(EscapeProperty(code!));
+ }
+
+ output.Append("::");
+ output.Append(EscapeData(message ?? string.Empty));
+ output.AppendLine();
+
+ _write(output.ToString());
+ }
+
+ ///
+ /// Escapes special characters in property values for GitHub Actions workflow commands.
+ ///
+ private static string EscapeProperty(string value)
+ {
+ if (string.IsNullOrEmpty(value))
+ {
+ return value;
+ }
+
+ return value.Replace("%", "%25")
+ .Replace("\r", "%0D")
+ .Replace("\n", "%0A")
+ .Replace(":", "%3A")
+ .Replace(",", "%2C");
+ }
+
+ ///
+ /// Escapes special characters in message data for GitHub Actions workflow commands.
+ ///
+ private static string EscapeData(string value)
+ {
+ if (string.IsNullOrEmpty(value))
+ {
+ return value;
+ }
+
+ return value.Replace("%", "%25")
+ .Replace("\r", "%0D")
+ .Replace("\n", "%0A");
+ }
+
+ #endregion
+}
diff --git a/src/Build/Logging/CICDLogger/GitLab/GitLabForwardingLogger.cs b/src/Build/Logging/CICDLogger/GitLab/GitLabForwardingLogger.cs
new file mode 100644
index 00000000000..2fda6ae9106
--- /dev/null
+++ b/src/Build/Logging/CICDLogger/GitLab/GitLabForwardingLogger.cs
@@ -0,0 +1,76 @@
+// 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;
+
+#nullable enable
+
+namespace Microsoft.Build.Logging.CICDLogger.GitLab;
+
+///
+/// Forwarding logger for GitLab CI that filters and forwards events from build nodes to the central logger.
+///
+public sealed class GitLabForwardingLogger : IForwardingLogger
+{
+ ///
+ public IEventRedirector? BuildEventRedirector { get; set; }
+
+ ///
+ public int NodeId { get; set; }
+
+ ///
+ public LoggerVerbosity Verbosity { get; set; } = LoggerVerbosity.Normal;
+
+ ///
+ public string? Parameters { get; set; }
+
+ ///
+ public void Initialize(IEventSource eventSource, int nodeCount)
+ {
+ NodeId = nodeCount;
+ Initialize(eventSource);
+ }
+
+ ///
+ public void Initialize(IEventSource eventSource)
+ {
+ // Forward all errors and warnings unconditionally
+ eventSource.ErrorRaised += ForwardEvent;
+ eventSource.WarningRaised += ForwardEvent;
+
+ // Forward build lifecycle events
+ eventSource.BuildStarted += ForwardEvent;
+ eventSource.BuildFinished += ForwardEvent;
+ eventSource.ProjectStarted += ForwardEvent;
+ eventSource.ProjectFinished += ForwardEvent;
+
+ // Forward messages based on importance and verbosity
+ eventSource.MessageRaised += MessageRaised;
+ }
+
+ ///
+ public void Shutdown()
+ {
+ }
+
+ private void ForwardEvent(object sender, BuildEventArgs e)
+ {
+ BuildEventRedirector?.ForwardEvent(e);
+ }
+
+ private void MessageRaised(object sender, BuildMessageEventArgs e)
+ {
+ // Forward messages based on verbosity
+ if (Verbosity == LoggerVerbosity.Quiet)
+ {
+ return;
+ }
+
+ if (e.Importance == MessageImportance.High ||
+ (e.Importance == MessageImportance.Normal && Verbosity >= LoggerVerbosity.Normal) ||
+ (e.Importance == MessageImportance.Low && Verbosity >= LoggerVerbosity.Detailed))
+ {
+ BuildEventRedirector?.ForwardEvent(e);
+ }
+ }
+}
diff --git a/src/Build/Logging/CICDLogger/GitLab/GitLabLogger.cs b/src/Build/Logging/CICDLogger/GitLab/GitLabLogger.cs
new file mode 100644
index 00000000000..9e182914b0e
--- /dev/null
+++ b/src/Build/Logging/CICDLogger/GitLab/GitLabLogger.cs
@@ -0,0 +1,340 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Microsoft.Build.Framework;
+
+#nullable enable
+
+namespace Microsoft.Build.Logging.CICDLogger.GitLab;
+
+///
+/// Data captured from project evaluation.
+///
+public sealed class GitLabEvalData
+{
+ public string? TargetFramework { get; set; }
+ public string? RuntimeIdentifier { get; set; }
+}
+
+///
+/// Wrapper for build events with timestamps to maintain ordering.
+///
+public sealed class TimestampedBuildEvent
+{
+ public DateTime Timestamp { get; }
+ public BuildEventArgs Event { get; }
+
+ public TimestampedBuildEvent(BuildEventArgs evt)
+ {
+ Event = evt;
+ Timestamp = evt.Timestamp;
+ }
+}
+
+///
+/// Data stored for each project during the build.
+///
+public sealed class GitLabProjectData
+{
+ public string? ProjectFile { get; set; }
+ public string? TargetFramework { get; set; }
+ public string? RuntimeIdentifier { get; set; }
+ public List Events { get; } = new();
+ public int SectionId { get; set; }
+
+ public int ErrorCount => Events.Count(e => e.Event is BuildErrorEventArgs);
+ public int WarningCount => Events.Count(e => e.Event is BuildWarningEventArgs);
+}
+
+///
+/// Data stored for the entire build session.
+///
+public sealed class GitLabBuildData
+{
+ public int TotalErrors { get; set; }
+ public int TotalWarnings { get; set; }
+ public int NextSectionId { get; set; } = 1;
+}
+
+///
+/// Logger for GitLab CI that formats build diagnostics using ANSI color codes and collapsible sections.
+/// See: https://docs.gitlab.com/ee/ci/jobs/#custom-collapsible-sections
+///
+public sealed class GitLabLogger : ProjectTrackingLoggerBase
+{
+ private Action _write = Console.Out.Write;
+
+ ///
+ public override LoggerVerbosity Verbosity { get; set; } = LoggerVerbosity.Normal;
+
+ ///
+ public override string? Parameters { get; set; }
+
+ ///
+ /// Detects if GitLab CI environment is active.
+ ///
+ /// true if running in GitLab CI; otherwise, false.
+ public static bool IsEnabled()
+ {
+ return !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("GITLAB_CI"));
+ }
+
+ ///
+ public override void Shutdown()
+ {
+ }
+
+ #region Abstract method implementations
+
+ protected override GitLabEvalData CreateEvalData(ProjectEvaluationFinishedEventArgs e)
+ {
+ var evalData = new GitLabEvalData();
+
+ // Extract target framework and runtime identifier from evaluation properties
+ if (e.Properties != null)
+ {
+ foreach (var item in e.Properties)
+ {
+ if (item is System.Collections.DictionaryEntry kvp)
+ {
+ var key = kvp.Key as string;
+ if (key == "TargetFramework")
+ {
+ evalData.TargetFramework = kvp.Value?.ToString();
+ }
+ else if (key == "RuntimeIdentifier")
+ {
+ evalData.RuntimeIdentifier = kvp.Value?.ToString();
+ }
+ }
+ }
+ }
+
+ return evalData;
+ }
+
+ protected override GitLabProjectData? CreateProjectData(GitLabEvalData evalData, ProjectStartedEventArgs e)
+ {
+ return new GitLabProjectData
+ {
+ ProjectFile = e.ProjectFile,
+ TargetFramework = evalData?.TargetFramework,
+ RuntimeIdentifier = evalData?.RuntimeIdentifier
+ };
+ }
+
+ protected override GitLabBuildData CreateBuildData(BuildStartedEventArgs e)
+ {
+ return new GitLabBuildData();
+ }
+
+ #endregion
+
+ #region Event handlers
+
+ protected override void OnErrorRaised(BuildErrorEventArgs e, GitLabProjectData? projectData, GitLabBuildData buildData)
+ {
+ buildData.TotalErrors++;
+
+ if (projectData != null)
+ {
+ // Buffer error for output at project finished
+ projectData.Events.Add(new TimestampedBuildEvent(e));
+ }
+ else
+ {
+ // No project context, write immediately
+ WriteDiagnostic("ERROR", "\x1b[31m", e.File, e.LineNumber, e.ColumnNumber, e.Code, e.Message);
+ }
+ }
+
+ protected override void OnWarningRaised(BuildWarningEventArgs e, GitLabProjectData? projectData, GitLabBuildData buildData)
+ {
+ buildData.TotalWarnings++;
+
+ if (projectData != null)
+ {
+ // Buffer warning for output at project finished
+ projectData.Events.Add(new TimestampedBuildEvent(e));
+ }
+ else
+ {
+ // No project context, write immediately
+ WriteDiagnostic("WARNING", "\x1b[33m", e.File, e.LineNumber, e.ColumnNumber, e.Code, e.Message);
+ }
+ }
+
+ protected override void OnMessageRaised(BuildMessageEventArgs e, GitLabProjectData? projectData, GitLabBuildData buildData)
+ {
+ if (Verbosity == LoggerVerbosity.Quiet)
+ {
+ return;
+ }
+
+ // First question: should I care about this message?
+ bool shouldLog = e.Importance == MessageImportance.High ||
+ (e.Importance == MessageImportance.Normal && Verbosity >= LoggerVerbosity.Normal) ||
+ (e.Importance == MessageImportance.Low && Verbosity >= LoggerVerbosity.Detailed);
+
+ if (!shouldLog)
+ {
+ return;
+ }
+
+ // Second question: do I log it now or at the end of the project build?
+ if (projectData != null)
+ {
+ // Buffer for output at project finished
+ projectData.Events.Add(new TimestampedBuildEvent(e));
+ }
+ else
+ {
+ // No project context, write immediately
+ _write(e.Message ?? string.Empty);
+ _write(Environment.NewLine);
+ }
+ }
+
+ protected override void OnProjectStarted(ProjectStartedEventArgs e, GitLabEvalData evalData, GitLabProjectData projectData, GitLabBuildData buildData)
+ {
+ // Assign section ID when project starts
+ projectData.SectionId = buildData.NextSectionId++;
+ }
+
+ protected override void OnProjectFinished(ProjectFinishedEventArgs e, GitLabProjectData projectData, GitLabBuildData buildData)
+ {
+ if (Verbosity >= LoggerVerbosity.Normal)
+ {
+ // Build project header with context information
+ var headerText = "Building ";
+ headerText += projectData.ProjectFile ?? e.ProjectFile ?? "project";
+
+ if (!string.IsNullOrEmpty(projectData.TargetFramework))
+ {
+ headerText += " (";
+ headerText += projectData.TargetFramework;
+
+ if (!string.IsNullOrEmpty(projectData.RuntimeIdentifier))
+ {
+ headerText += " | ";
+ headerText += projectData.RuntimeIdentifier;
+ }
+
+ headerText += ")";
+ }
+
+ // Add success/failure status
+ if (!e.Succeeded)
+ {
+ headerText += " - Failed";
+ }
+ else if (projectData.ErrorCount > 0 || projectData.WarningCount > 0)
+ {
+ headerText += $" - {projectData.ErrorCount} error(s), {projectData.WarningCount} warning(s)";
+ }
+
+ // Use collapsible sections in GitLab CI
+ // Format: \e[0Ksection_start:TIMESTAMP:SECTION_NAME\r\e[0K
+ var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
+ var sectionName = $"build_project_{projectData.SectionId}";
+
+ _write($"\x1b[0Ksection_start:{timestamp}:{sectionName}\r\x1b[0K");
+ _write($"\x1b[36m{headerText}\x1b[0m"); // Cyan color for header
+ _write(Environment.NewLine);
+
+ // Output all events in timestamp order
+ foreach (var timestampedEvent in projectData.Events.OrderBy(e => e.Timestamp))
+ {
+ switch (timestampedEvent.Event)
+ {
+ case BuildErrorEventArgs error:
+ WriteDiagnostic("ERROR", "\x1b[31m", error.File, error.LineNumber, error.ColumnNumber, error.Code, error.Message);
+ break;
+ case BuildWarningEventArgs warning:
+ WriteDiagnostic("WARNING", "\x1b[33m", warning.File, warning.LineNumber, warning.ColumnNumber, warning.Code, warning.Message);
+ break;
+ case BuildMessageEventArgs message:
+ _write(message.Message ?? string.Empty);
+ _write(Environment.NewLine);
+ break;
+ }
+ }
+
+ // End collapsible section
+ // Format: \e[0Ksection_end:TIMESTAMP:SECTION_NAME\r\e[0K
+ timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
+ _write($"\x1b[0Ksection_end:{timestamp}:{sectionName}\r\x1b[0K");
+ }
+ }
+
+ protected override void OnBuildFinished(BuildFinishedEventArgs e, GitLabProjectData[] projectData, GitLabBuildData buildData)
+ {
+ if (Verbosity >= LoggerVerbosity.Minimal)
+ {
+ if (e.Succeeded)
+ {
+ _write("\x1b[32m"); // Green
+ _write($"Build succeeded. {buildData.TotalWarnings} warning(s)");
+ }
+ else
+ {
+ _write("\x1b[31m"); // Red
+ _write($"Build failed. {buildData.TotalErrors} error(s), {buildData.TotalWarnings} warning(s)");
+ }
+ _write("\x1b[0m"); // Reset color
+ _write(Environment.NewLine);
+ }
+ }
+
+ #endregion
+
+ #region Helper methods
+
+ ///
+ /// Writes a diagnostic (error or warning) using GitLab formatting.
+ ///
+ private void WriteDiagnostic(string type, string colorCode, string? file, int lineNumber, int columnNumber, string? code, string? message)
+ {
+ // GitLab uses ANSI color codes for formatting
+ // Only colorize the type for better legibility
+ _write(colorCode); // Color code (red for errors, yellow for warnings)
+ _write(type);
+ _write("\x1b[0m"); // Reset color after type
+ _write(": ");
+
+ if (!string.IsNullOrEmpty(file))
+ {
+ _write(file!);
+
+ if (lineNumber > 0)
+ {
+ _write("(");
+ _write(lineNumber.ToString());
+
+ if (columnNumber > 0)
+ {
+ _write(",");
+ _write(columnNumber.ToString());
+ }
+
+ _write(")");
+ }
+
+ _write(": ");
+ }
+
+ if (!string.IsNullOrEmpty(code))
+ {
+ _write(code!);
+ _write(": ");
+ }
+
+ _write(message ?? string.Empty);
+ _write(Environment.NewLine);
+ }
+
+ #endregion
+}
diff --git a/src/Build/Logging/ProjectTrackingLoggerBase.cs b/src/Build/Logging/ProjectTrackingLoggerBase.cs
new file mode 100644
index 00000000000..375257ba117
--- /dev/null
+++ b/src/Build/Logging/ProjectTrackingLoggerBase.cs
@@ -0,0 +1,430 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Collections.Generic;
+using System.Linq;
+using Microsoft.Build.Framework;
+
+namespace Microsoft.Build.Logging;
+
+///
+/// A wrapper over the project context ID passed to us in logger events.
+///
+internal record struct ProjectContext(int Id)
+{
+ public ProjectContext(BuildEventContext context)
+ : this(context.ProjectContextId)
+ {
+ }
+}
+
+///
+/// A wrapper over the evaluation context ID passed to us in logger events.
+///
+internal record struct EvalContext(int Id)
+{
+ public EvalContext(BuildEventContext context)
+ : this(context.EvaluationId)
+ {
+ }
+}
+
+///
+/// Base class that tracks build state (evaluation data, node status, completed project data, and whole-build data) during builds.
+/// Subclasses can specialize the type of data tracked for each area and implement rendering logic.
+///
+/// Data gathered/projected for each evaluation context
+/// Data stored for each live, actively running build worker node
+/// Data stored for each completed project context instance
+/// Data that is aggregated across the entire build session
+public abstract class ProjectTrackingLoggerBase : INodeLogger
+{
+
+ ///
+ /// Tracks the evaluation data for all evaluations seen so far.
+ ///
+ ///
+ /// Keyed by an ID that gets passed to logger callbacks, this allows us to quickly look up the corresponding evaluation.
+ ///
+ private readonly Dictionary _evaluationDataByEvalId = new();
+
+ ///
+ /// Tracks the status of all relevant projects seen so far.
+ ///
+ ///
+ /// Keyed by an ID that gets passed to logger callbacks, this allows us to quickly look up the corresponding project.
+ ///
+ private readonly Dictionary _projectDataByProjectContextId = new();
+
+ ///
+ /// Tracks build-level data for the entire build session.
+ ///
+ private TBuildData? _buildData;
+
+ #region INodeLogger implementation
+
+ ///
+ public abstract LoggerVerbosity Verbosity { get; set; }
+
+ ///
+ public abstract string? Parameters { get; set; }
+
+ ///
+ /// The number of nodes in the build. Handles the case where MSBUILDNOINPROCNODE is set by reserving an extra slot.
+ ///
+ protected int NodeCount { get; private set; }
+
+ ///
+ public virtual void Initialize(IEventSource eventSource, int nodeCount)
+ {
+ // When MSBUILDNOINPROCNODE enabled, NodeId's reported by build start with 2. We need to reserve an extra spot for this case.
+ NodeCount = nodeCount + 1;
+
+ Initialize(eventSource);
+ }
+
+ ///
+ public virtual void Initialize(IEventSource eventSource)
+ {
+ eventSource.BuildStarted += BuildStartedHandler;
+ eventSource.BuildFinished += BuildFinishedHandler;
+ eventSource.ProjectStarted += ProjectStartedHandler;
+ eventSource.ProjectFinished += ProjectFinishedHandler;
+ eventSource.TargetStarted += TargetStartedHandler;
+ eventSource.TargetFinished += TargetFinishedHandler;
+ eventSource.TaskStarted += TaskStartedHandler;
+ eventSource.StatusEventRaised += StatusEventRaisedHandler;
+ eventSource.MessageRaised += MessageRaisedHandler;
+ eventSource.WarningRaised += WarningRaisedHandler;
+ eventSource.ErrorRaised += ErrorRaisedHandler;
+
+ if (eventSource is IEventSource3 eventSource3)
+ {
+ eventSource3.IncludeTaskInputs();
+ }
+
+ if (eventSource is IEventSource4 eventSource4)
+ {
+ eventSource4.IncludeEvaluationPropertiesAndItems();
+ }
+ }
+
+ ///
+ public abstract void Shutdown();
+
+ #endregion
+
+ #region Logger callbacks
+
+ ///
+ /// The callback.
+ ///
+ private void BuildStartedHandler(object sender, BuildStartedEventArgs e)
+ {
+ _buildData = CreateBuildData(e);
+ OnBuildStarted(e, _buildData);
+ }
+
+ ///
+ /// The callback.
+ ///
+ private void BuildFinishedHandler(object sender, BuildFinishedEventArgs e)
+ {
+ OnBuildFinished(e, _projectDataByProjectContextId.Values.ToArray(), _buildData!);
+
+ // Clear tracking data
+ _projectDataByProjectContextId.Clear();
+ _evaluationDataByEvalId.Clear();
+ _buildData = default;
+ }
+
+ ///
+ /// The callback.
+ ///
+ private void StatusEventRaisedHandler(object sender, BuildStatusEventArgs e)
+ {
+ switch (e)
+ {
+ case BuildCanceledEventArgs cancelEvent:
+ OnBuildCanceled(cancelEvent);
+ break;
+ case ProjectEvaluationStartedEventArgs:
+ break;
+ case ProjectEvaluationFinishedEventArgs evalFinish:
+ CaptureEvalContext(evalFinish);
+ break;
+ }
+ }
+
+ ///
+ /// The callback.
+ ///
+ private void ProjectStartedHandler(object sender, ProjectStartedEventArgs e)
+ {
+ if (e.BuildEventContext is null)
+ {
+ return;
+ }
+
+ ProjectContext projectContext = new(e.BuildEventContext);
+ EvalContext evalContext = new(e.BuildEventContext);
+
+ // Get eval data for this project
+ if (_evaluationDataByEvalId.TryGetValue(evalContext, out TEvalData? evalData))
+ {
+ // Create project data using the eval data
+ TProjectData? projectData = CreateProjectData(evalData, e);
+ if (projectData != null)
+ {
+ _projectDataByProjectContextId[projectContext] = projectData;
+ OnProjectStarted(e, evalData, projectData!, _buildData!);
+ }
+ }
+ }
+
+ ///
+ /// The callback.
+ ///
+ private void ProjectFinishedHandler(object sender, ProjectFinishedEventArgs e)
+ {
+ var buildEventContext = e.BuildEventContext;
+ if (buildEventContext is null)
+ {
+ return;
+ }
+
+ ProjectContext projectContext = new(buildEventContext);
+
+ // Get project data
+ if (_projectDataByProjectContextId.TryGetValue(projectContext, out var projectData))
+ {
+ OnProjectFinished(e, projectData, _buildData!);
+ }
+ }
+
+ ///
+ /// The callback.
+ ///
+ private void TargetStartedHandler(object sender, TargetStartedEventArgs e)
+ {
+ var buildEventContext = e.BuildEventContext;
+ if (buildEventContext is not null)
+ {
+ ProjectContext projectContext = new(buildEventContext);
+ if (_projectDataByProjectContextId.TryGetValue(projectContext, out TProjectData? projectData))
+ {
+ OnTargetStarted(e, projectData, _buildData!);
+ }
+ }
+ }
+
+ ///
+ /// The callback.
+ ///
+ private void TargetFinishedHandler(object sender, TargetFinishedEventArgs e)
+ {
+ var buildEventContext = e.BuildEventContext;
+ if (buildEventContext is null)
+ {
+ return;
+ }
+
+ ProjectContext projectContext = new(buildEventContext);
+ if (_projectDataByProjectContextId.TryGetValue(projectContext, out var projectData))
+ {
+ OnTargetFinished(e, projectData, _buildData!);
+ }
+ }
+
+ ///
+ /// The callback.
+ ///
+ private void TaskStartedHandler(object sender, TaskStartedEventArgs e)
+ {
+ var buildEventContext = e.BuildEventContext;
+ if (buildEventContext is not null)
+ {
+ ProjectContext projectContext = new(buildEventContext);
+ if (_projectDataByProjectContextId.TryGetValue(projectContext, out var projectData))
+ {
+ OnTaskStarted(e, projectData, _buildData!);
+ }
+ }
+ }
+
+ ///
+ /// The callback.
+ ///
+ private void MessageRaisedHandler(object sender, BuildMessageEventArgs e)
+ {
+ var buildEventContext = e.BuildEventContext;
+ if (buildEventContext is null)
+ {
+ return;
+ }
+
+ ProjectContext projectContext = new(buildEventContext);
+ TProjectData? projectData = default;
+ _projectDataByProjectContextId.TryGetValue(projectContext, out projectData);
+ OnMessageRaised(e, projectData, _buildData!);
+ }
+
+ ///
+ /// The callback.
+ ///
+ private void WarningRaisedHandler(object sender, BuildWarningEventArgs e)
+ {
+ BuildEventContext? buildEventContext = e.BuildEventContext;
+ if (buildEventContext is null)
+ {
+ OnWarningRaised(e, default, _buildData!);
+ return;
+ }
+
+ ProjectContext projectContext = new(buildEventContext);
+ TProjectData? projectData = default;
+ _projectDataByProjectContextId.TryGetValue(projectContext, out projectData);
+ OnWarningRaised(e, projectData, _buildData!);
+ }
+
+ ///
+ /// The callback.
+ ///
+ private void ErrorRaisedHandler(object sender, BuildErrorEventArgs e)
+ {
+ BuildEventContext? buildEventContext = e.BuildEventContext;
+ if (buildEventContext is null)
+ {
+ OnErrorRaised(e, default, _buildData!);
+ return;
+ }
+
+ ProjectContext projectContext = new(buildEventContext);
+ TProjectData? projectData = default;
+ _projectDataByProjectContextId.TryGetValue(projectContext, out projectData);
+ OnErrorRaised(e, projectData, _buildData!);
+ }
+
+ #endregion
+
+ #region Protected helpers
+
+ protected int? GetNodeIdForEvent(BuildEventArgs args) => args?.BuildEventContext is null ? null : NodeIndexForContext(args.BuildEventContext);
+
+ #endregion
+
+ #region Private helpers
+
+ private int NodeIndexForContext(BuildEventContext context)
+ {
+ // Node IDs reported by the build are 1-based.
+ return context.NodeId - 1;
+ }
+
+ ///
+ /// Captures evaluation context data from the evaluation finished event.
+ ///
+ private void CaptureEvalContext(ProjectEvaluationFinishedEventArgs evalFinish)
+ {
+ var buildEventContext = evalFinish.BuildEventContext;
+ if (buildEventContext is null)
+ {
+ return;
+ }
+
+ EvalContext evalContext = new(buildEventContext);
+
+ if (!_evaluationDataByEvalId.ContainsKey(evalContext))
+ {
+ TEvalData evalData = CreateEvalData(evalFinish);
+ _evaluationDataByEvalId[evalContext] = evalData;
+ }
+ }
+
+ #endregion
+
+ #region Abstract methods - must be implemented by subclasses
+
+ ///
+ /// Creates evaluation data from the evaluation finished event.
+ ///
+ /// The evaluation finished event args.
+ /// The evaluation data to store, or null to not track this evaluation.
+ protected abstract TEvalData CreateEvalData(ProjectEvaluationFinishedEventArgs e);
+
+ ///
+ /// Creates project data from the project started event and evaluation data.
+ ///
+ /// The evaluation data for this project.
+ /// The project started event args.
+ /// The project data to store, or null to not track this project.
+ protected abstract TProjectData? CreateProjectData(TEvalData evalData, ProjectStartedEventArgs e);
+
+ ///
+ /// Creates build data when the build starts.
+ ///
+ /// The build started event args.
+ /// The build data to track for this build session.
+ protected abstract TBuildData CreateBuildData(BuildStartedEventArgs e);
+
+ #endregion
+
+ #region Virtual methods - can be overridden by subclasses
+
+ ///
+ /// Called when the build starts.
+ ///
+ protected virtual void OnBuildStarted(BuildStartedEventArgs e, TBuildData buildData) { }
+
+ ///
+ /// Called when the build finishes.
+ ///
+ protected virtual void OnBuildFinished(BuildFinishedEventArgs e, TProjectData[] projectData, TBuildData buildData) { }
+
+ ///
+ /// Called when the build is canceled.
+ ///
+ protected virtual void OnBuildCanceled(BuildCanceledEventArgs e) { }
+
+ ///
+ /// Called when a project starts.
+ ///
+ protected virtual void OnProjectStarted(ProjectStartedEventArgs e, TEvalData evalData, TProjectData projectData, TBuildData buildData) { }
+
+ ///
+ /// Called when a project finishes.
+ ///
+ protected virtual void OnProjectFinished(ProjectFinishedEventArgs e, TProjectData projectData, TBuildData buildData) { }
+
+ ///
+ /// Called when a target starts.
+ ///
+ protected virtual void OnTargetStarted(TargetStartedEventArgs e, TProjectData projectData, TBuildData buildData) { }
+
+ ///
+ /// Called when a target finishes.
+ ///
+ protected virtual void OnTargetFinished(TargetFinishedEventArgs e, TProjectData projectData, TBuildData buildData) { }
+
+ ///
+ /// Called when a task starts.
+ ///
+ protected virtual void OnTaskStarted(TaskStartedEventArgs e, TProjectData projectData, TBuildData buildData) { }
+
+ ///
+ /// Called when a message is raised.
+ ///
+ protected virtual void OnMessageRaised(BuildMessageEventArgs e, TProjectData? projectData, TBuildData buildData) { }
+
+ ///
+ /// Called when a warning is raised.
+ ///
+ protected virtual void OnWarningRaised(BuildWarningEventArgs e, TProjectData? projectData, TBuildData buildData) { }
+
+ ///
+ /// Called when an error is raised.
+ ///
+ protected virtual void OnErrorRaised(BuildErrorEventArgs e, TProjectData? projectData, TBuildData buildData) { }
+
+ #endregion
+}
\ No newline at end of file
diff --git a/src/Build/Logging/TerminalLogger/StopwatchAbstraction.cs b/src/Build/Logging/TerminalLogger/StopwatchAbstraction.cs
index c4f72f630de..d6c3920910c 100644
--- a/src/Build/Logging/TerminalLogger/StopwatchAbstraction.cs
+++ b/src/Build/Logging/TerminalLogger/StopwatchAbstraction.cs
@@ -3,7 +3,7 @@
namespace Microsoft.Build.Logging;
-internal abstract class StopwatchAbstraction
+public abstract class StopwatchAbstraction
{
public abstract void Start();
public abstract void Stop();
diff --git a/src/Build/Logging/TerminalLogger/TerminalBuildData.cs b/src/Build/Logging/TerminalLogger/TerminalBuildData.cs
new file mode 100644
index 00000000000..9ef619c8443
--- /dev/null
+++ b/src/Build/Logging/TerminalLogger/TerminalBuildData.cs
@@ -0,0 +1,51 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System;
+
+namespace Microsoft.Build.Logging;
+
+///
+/// Tracks build-level data for the TerminalLogger across an entire build session.
+///
+public sealed class TerminalBuildData
+{
+ ///
+ /// The timestamp of the build start event.
+ ///
+ public DateTime BuildStartTime { get; set; }
+
+ ///
+ /// Number of build errors encountered during the build.
+ ///
+ public int BuildErrorsCount { get; set; }
+
+ ///
+ /// Number of build warnings encountered during the build.
+ ///
+ public int BuildWarningsCount { get; set; }
+
+ ///
+ /// The project build context corresponding to the Restore initial target, or null if the build is currently not restoring.
+ ///
+ public int? RestoreContext { get; set; }
+
+ ///
+ /// True if restore failed and this failure has already been reported.
+ ///
+ public bool RestoreFailed { get; set; }
+
+ ///
+ /// True if restore happened and finished.
+ ///
+ public bool RestoreFinished { get; set; }
+
+ ///
+ /// Initializes a new instance of TerminalBuildData.
+ ///
+ /// The timestamp when the build started.
+ public TerminalBuildData(DateTime buildStartTime)
+ {
+ BuildStartTime = buildStartTime;
+ }
+}
\ No newline at end of file
diff --git a/src/Build/Logging/TerminalLogger/TerminalBuildMessage.cs b/src/Build/Logging/TerminalLogger/TerminalBuildMessage.cs
index 8e90b6f85e2..1097ff85b61 100644
--- a/src/Build/Logging/TerminalLogger/TerminalBuildMessage.cs
+++ b/src/Build/Logging/TerminalLogger/TerminalBuildMessage.cs
@@ -6,5 +6,5 @@ namespace Microsoft.Build.Logging;
///
/// Represents a piece of diagnostic output (message/warning/error).
///
-internal record struct TerminalBuildMessage(TerminalMessageSeverity Severity, string Message)
+public record struct TerminalBuildMessage(TerminalMessageSeverity Severity, string Message)
{ }
diff --git a/src/Build/Logging/TerminalLogger/TerminalLogger.cs b/src/Build/Logging/TerminalLogger/TerminalLogger.cs
index b64fefb705e..ef9a99704c8 100644
--- a/src/Build/Logging/TerminalLogger/TerminalLogger.cs
+++ b/src/Build/Logging/TerminalLogger/TerminalLogger.cs
@@ -30,7 +30,7 @@ namespace Microsoft.Build.Logging;
///
/// Uses ANSI/VT100 control codes to erase and overwrite lines as the build is progressing.
///
-public sealed partial class TerminalLogger : INodeLogger
+public sealed partial class TerminalLogger : ProjectTrackingLoggerBase
{
private const string FilePathPattern = " -> ";
@@ -42,27 +42,7 @@ public sealed partial class TerminalLogger : INodeLogger
private static readonly string[] newLineStrings = { "\r\n", "\n" };
- ///
- /// A wrapper over the project context ID passed to us in logger events.
- ///
- internal record struct ProjectContext(int Id)
- {
- public ProjectContext(BuildEventContext context)
- : this(context.ProjectContextId)
- {
- }
- }
-
- ///
- /// A wrapper over the evaluation context ID passed to us in logger events.
- ///
- internal record struct EvalContext(int Id)
- {
- public EvalContext(BuildEventContext context)
- : this(context.EvaluationId)
- {
- }
- }
+ // ProjectContext and EvalContext are now inherited from BuildTrackerLogger
private readonly record struct TestSummary(int Total, int Passed, int Skipped, int Failed);
@@ -80,31 +60,6 @@ public EvalContext(BuildEventContext context)
internal Func? CreateStopwatch = null;
- ///
- /// Name of target that identifies the project cache plugin run has just started.
- ///
- private const string CachePluginStartTarget = "_CachePluginRunStart";
-
- ///
- /// Protects access to state shared between the logger callbacks and the rendering thread.
- ///
- private readonly LockType _lock = new LockType();
-
- ///
- /// A cancellation token to signal the rendering thread that it should exit.
- ///
- private readonly CancellationTokenSource _cts = new();
-
- ///
- /// Tracks the status of all relevant projects seen so far.
- ///
- ///
- /// Keyed by an ID that gets passed to logger callbacks, this allows us to quickly look up the corresponding project.
- ///
- private readonly Dictionary _projects = new();
-
- private readonly Dictionary _evals = new();
-
///
/// Tracks the work currently being done by build nodes. Null means the node is not doing any work worth reporting.
///
@@ -112,43 +67,33 @@ public EvalContext(BuildEventContext context)
/// There is no locking around access to this data structure despite it being accessed concurrently by multiple threads.
/// However, reads and writes to locations in an array is atomic, so locking is not required.
///
- private TerminalNodeStatus?[] _nodes = Array.Empty();
+ private TerminalNodeStatus?[] _nodes = [];
///
- /// The timestamp of the event.
+ /// Name of target that identifies the project cache plugin run has just started.
///
- private DateTime _buildStartTime;
+ private const string CachePluginStartTarget = "_CachePluginRunStart";
///
- /// The working directory when the build starts, to trim relative output paths.
+ /// Protects access to state shared between the logger callbacks and the rendering thread.
///
- private readonly string _initialWorkingDirectory = Environment.CurrentDirectory;
+ private readonly LockType _lock = new LockType();
///
- /// Number of build errors.
+ /// A cancellation token to signal the rendering thread that it should exit.
///
- private int _buildErrorsCount;
+ private readonly CancellationTokenSource _cts = new();
- ///
- /// Number of build warnings.
- ///
- private int _buildWarningsCount;
+ // Tracking dictionaries and node array are now inherited from BuildTrackerLogger
- ///
- /// True if restore failed and this failure has already been reported.
- ///
- private bool _restoreFailed;
+ // BuildStartTime is now inherited from BuildTrackerLogger
///
- /// True if restore happened and finished.
+ /// The working directory when the build starts, to trim relative output paths.
///
- private bool _restoreFinished = false;
+ private readonly string _initialWorkingDirectory = Environment.CurrentDirectory;
- ///
- /// The project build context corresponding to the Restore initial target, or null if the build is currently
- /// not restoring.
- ///
- private ProjectContext? _restoreContext;
+ // Build error/warning counts and restore state are now inherited from BuildTrackerLogger
///
/// The thread that performs periodic refresh of the console output.
@@ -347,49 +292,19 @@ private static bool IsTerminalLoggerDisabled(string? value) =>
#region INodeLogger implementation
///
- public LoggerVerbosity Verbosity { get; set; } = LoggerVerbosity.Minimal;
-
- ///
- public string? Parameters { get; set; } = null;
+ public override LoggerVerbosity Verbosity { get; set; } = LoggerVerbosity.Minimal;
///
- public void Initialize(IEventSource eventSource, int nodeCount)
- {
- // When MSBUILDNOINPROCNODE enabled, NodeId's reported by build start with 2. We need to reserve an extra spot for this case.
- _nodes = new TerminalNodeStatus[nodeCount + 1];
-
- Initialize(eventSource);
- }
+ public override string? Parameters { get; set; } = null;
///
- public void Initialize(IEventSource eventSource)
+ public override void Initialize(IEventSource eventSource)
{
ParseParameters();
-
- eventSource.BuildStarted += BuildStarted;
- eventSource.BuildFinished += BuildFinished;
- eventSource.ProjectStarted += ProjectStarted;
- eventSource.ProjectFinished += ProjectFinished;
- eventSource.TargetStarted += TargetStarted;
- eventSource.TargetFinished += TargetFinished;
- eventSource.TaskStarted += TaskStarted;
- eventSource.StatusEventRaised += StatusEventRaised;
- eventSource.MessageRaised += MessageRaised;
- eventSource.WarningRaised += WarningRaised;
- eventSource.ErrorRaised += ErrorRaised;
-
- if (eventSource is IEventSource3 eventSource3)
- {
- eventSource3.IncludeTaskInputs();
- }
-
- if (eventSource is IEventSource4 eventSource4)
- {
- eventSource4.IncludeEvaluationPropertiesAndItems();
- }
+ base.Initialize(eventSource);
+ _nodes = new TerminalNodeStatus[NodeCount];
}
-
///
/// Parses out the logger parameters from the Parameters string.
///
@@ -467,7 +382,7 @@ private bool TryApplyShowCommandLineParameter(string? parameterValue)
}
///
- public void Shutdown()
+ public override void Shutdown()
{
NativeMethodsShared.RestoreConsoleMode(_originalConsoleMode);
@@ -489,12 +404,77 @@ public MessageImportance GetMinimumMessageImportance()
#endregion
- #region Logger callbacks
+ #region BuildTrackerLogger implementation
- ///
- /// The callback.
- ///
- private void BuildStarted(object sender, BuildStartedEventArgs e)
+ ///
+ protected override TerminalBuildData CreateBuildData(BuildStartedEventArgs e)
+ {
+ return new TerminalBuildData(e.Timestamp);
+ }
+
+ ///
+ protected override EvalProjectInfo CreateEvalData(ProjectEvaluationFinishedEventArgs e)
+ {
+ string? tfm = null;
+ string? rid = null;
+ foreach (var property in e.EnumerateProperties())
+ {
+ if (tfm is not null && rid is not null)
+ {
+ // We already have both properties, no need to continue.
+ break;
+ }
+ switch (property.Name)
+ {
+ case "TargetFramework":
+ tfm = property.Value;
+ break;
+ case "RuntimeIdentifier":
+ rid = property.Value;
+ break;
+ }
+ }
+ return new EvalProjectInfo(e.ProjectFile!, tfm, rid);
+ }
+
+ ///
+ protected override TerminalProjectInfo? CreateProjectData(EvalProjectInfo evalData, ProjectStartedEventArgs e)
+ {
+ return new TerminalProjectInfo(evalData, CreateStopwatch?.Invoke());
+ }
+
+ private TerminalNodeStatus? CreateNodeData(TargetStartedEventArgs e, TerminalProjectInfo projectData)
+ {
+ projectData.Stopwatch.Start();
+ string projectFile = Path.GetFileNameWithoutExtension(e.ProjectFile);
+ string targetName = e.TargetName;
+
+ if (targetName == CachePluginStartTarget)
+ {
+ projectData.IsCachePluginProject = true;
+ _hasUsedCache = true;
+ }
+
+ if (targetName == _testStartTarget)
+ {
+ targetName = "Testing";
+ _testStartTime = _testStartTime == null
+ ? e.Timestamp
+ : e.Timestamp < _testStartTime
+ ? e.Timestamp : _testStartTime;
+ projectData.IsTestProject = true;
+ }
+
+ return new TerminalNodeStatus(projectFile, projectData.TargetFramework, projectData.RuntimeIdentifier, targetName, projectData.Stopwatch);
+ }
+
+
+ #endregion
+
+ #region Logger event overrides
+
+ ///
+ protected override void OnBuildStarted(BuildStartedEventArgs e, TerminalBuildData buildData)
{
if (!_manualRefresh && _showNodesDisplay)
{
@@ -503,18 +483,14 @@ private void BuildStarted(object sender, BuildStartedEventArgs e)
_refresher.Start();
}
- _buildStartTime = e.Timestamp;
-
if (Terminal.SupportsProgressReporting && Verbosity != LoggerVerbosity.Quiet)
{
Terminal.Write(AnsiCodes.SetProgressIndeterminate);
}
}
- ///
- /// The callback.
- ///
- private void BuildFinished(object sender, BuildFinishedEventArgs e)
+ ///
+ protected override void OnBuildFinished(BuildFinishedEventArgs e, TerminalProjectInfo[] projectInfos, TerminalBuildData buildData)
{
_cts.Cancel();
_refresher?.Join();
@@ -524,8 +500,8 @@ private void BuildFinished(object sender, BuildFinishedEventArgs e)
{
if (Verbosity > LoggerVerbosity.Quiet)
{
- string duration = (e.Timestamp - _buildStartTime).TotalSeconds.ToString("F1");
- string buildResult = GetBuildResultString(e.Succeeded, _buildErrorsCount, _buildWarningsCount);
+ string duration = (e.Timestamp - buildData.BuildStartTime).TotalSeconds.ToString("F1");
+ string buildResult = GetBuildResultString(e.Succeeded, buildData.BuildErrorsCount, buildData.BuildWarningsCount);
Terminal.WriteLine("");
if (_testRunSummaries.Any())
@@ -537,8 +513,8 @@ private void BuildFinished(object sender, BuildFinishedEventArgs e)
var testDuration = (_testStartTime != null && _testEndTime != null ? (_testEndTime - _testStartTime).Value.TotalSeconds : 0).ToString("F1");
var colorizeFailed = failed > 0;
- var colorizePassed = passed > 0 && _buildErrorsCount == 0 && failed == 0;
- var colorizeSkipped = skipped > 0 && skipped == total && _buildErrorsCount == 0 && failed == 0;
+ var colorizePassed = passed > 0 && buildData.BuildErrorsCount == 0 && failed == 0;
+ var colorizeSkipped = skipped > 0 && skipped == total && buildData.BuildErrorsCount == 0 && failed == 0;
string summaryAndTotalText = ResourceUtilities.FormatResourceStringIgnoreCodeAndKeyword("TestSummary_BannerAndTotal", total);
string failedText = ResourceUtilities.FormatResourceStringIgnoreCodeAndKeyword("TestSummary_Failed", failed);
@@ -555,10 +531,10 @@ private void BuildFinished(object sender, BuildFinishedEventArgs e)
if (_showSummary == true)
{
- RenderBuildSummary();
+ RenderBuildSummary(buildData, projectInfos);
}
- if (_restoreFailed)
+ if (buildData?.RestoreFailed == true)
{
Terminal.WriteLine(ResourceUtilities.FormatResourceStringIgnoreCodeAndKeyword("RestoreCompleteWithMessage",
buildResult,
@@ -582,18 +558,14 @@ private void BuildFinished(object sender, BuildFinishedEventArgs e)
Terminal.EndUpdate();
}
- _projects.Clear();
_testRunSummaries.Clear();
- _buildErrorsCount = 0;
- _buildWarningsCount = 0;
- _restoreFailed = false;
_testStartTime = null;
_testEndTime = null;
}
- private void RenderBuildSummary()
+ private void RenderBuildSummary(TerminalBuildData buildData, TerminalProjectInfo[] projectInfos)
{
- if (_buildErrorsCount == 0 && _buildWarningsCount == 0)
+ if (buildData.BuildErrorsCount == 0 && buildData.BuildWarningsCount == 0)
{
// No errors/warnings to display.
return;
@@ -601,7 +573,7 @@ private void RenderBuildSummary()
Terminal.WriteLine(ResourceUtilities.GetResourceString("BuildSummary"));
- foreach (TerminalProjectInfo project in _projects.Values.Where(p => p.HasErrorsOrWarnings))
+ foreach (TerminalProjectInfo project in projectInfos.Where(p => p.HasErrorsOrWarnings))
{
string duration = project.Stopwatch.ElapsedSeconds.ToString("F1");
string buildResult = GetBuildResultString(project.Succeeded, project.ErrorCount, project.WarningCount);
@@ -618,62 +590,25 @@ private void RenderBuildSummary()
Terminal.WriteLine(string.Empty);
}
- private void StatusEventRaised(object sender, BuildStatusEventArgs e)
+ ///
+ protected override void OnBuildCanceled(BuildCanceledEventArgs e)
{
- switch (e)
- {
- case BuildCanceledEventArgs cancelEvent:
- RenderImmediateMessage(cancelEvent.Message!);
- break;
- case ProjectEvaluationStartedEventArgs _evalStart:
- break;
- case ProjectEvaluationFinishedEventArgs evalFinish:
- CaptureEvalContext(evalFinish);
- break;
- }
+ RenderImmediateMessage(e.Message!);
}
- ///
- /// The callback.
- ///
- private void ProjectStarted(object sender, ProjectStartedEventArgs e)
+ ///
+ protected override void OnProjectStarted(ProjectStartedEventArgs e, EvalProjectInfo evalData, TerminalProjectInfo projectData, TerminalBuildData buildData)
{
- if (e.BuildEventContext is null)
+ // Handle restore case
+ if (buildData.RestoreContext is null && e.TargetNames == "Restore" && !buildData.RestoreFinished && e.BuildEventContext is not null)
{
- return;
- }
-
- ProjectContext c = new(e.BuildEventContext);
-
- if (_restoreContext is null)
- {
- EvalContext evalContext = new(e.BuildEventContext);
- string? targetFramework = null;
- string? runtimeIdentifier = null;
- if (_evals.TryGetValue(evalContext, out EvalProjectInfo evalInfo))
- {
- targetFramework = evalInfo.TargetFramework;
- runtimeIdentifier = evalInfo.RuntimeIdentifier;
- }
- System.Diagnostics.Debug.Assert(evalInfo != default, "EvalProjectInfo should have been captured before ProjectStarted");
-
- TerminalProjectInfo projectInfo = new(c, evalInfo, CreateStopwatch?.Invoke());
- _projects[c] = projectInfo;
-
- // First ever restore in the build is starting.
- if (e.TargetNames == "Restore" && !_restoreFinished)
- {
- _restoreContext = c;
- int nodeIndex = NodeIndexForContext(e.BuildEventContext);
- _nodes[nodeIndex] = new TerminalNodeStatus(e.ProjectFile!, targetFramework, runtimeIdentifier, "Restore", _projects[c].Stopwatch);
- }
+ buildData.RestoreContext = e.BuildEventContext.ProjectContextId;
+ StartNode(e, new TerminalNodeStatus(e.ProjectFile!, evalData.TargetFramework, evalData.RuntimeIdentifier, "Restore", projectData.Stopwatch));
}
}
- ///
- /// The callback.
- ///
- private void ProjectFinished(object sender, ProjectFinishedEventArgs e)
+ ///
+ protected override void OnProjectFinished(ProjectFinishedEventArgs e, TerminalProjectInfo? projectData, TerminalBuildData buildData)
{
var buildEventContext = e.BuildEventContext;
if (buildEventContext is null)
@@ -682,9 +617,9 @@ private void ProjectFinished(object sender, ProjectFinishedEventArgs e)
}
// Mark node idle until something uses it again
- if (_restoreContext is null)
+ if (buildData.RestoreContext is null)
{
- UpdateNodeStatus(buildEventContext, null);
+ YieldNode(e);
}
// Continue execution and add project summary to the static part of the Console only if verbosity is higher than Quiet.
@@ -693,12 +628,20 @@ private void ProjectFinished(object sender, ProjectFinishedEventArgs e)
return;
}
- ProjectContext c = new(buildEventContext);
-
- if (_projects.TryGetValue(c, out TerminalProjectInfo? project))
+ if (projectData != null)
{
- project.Succeeded = e.Succeeded;
- project.Stopwatch.Stop();
+ projectData.Succeeded = e.Succeeded;
+ projectData.Stopwatch.Stop();
+
+ // Handle restore completion
+ if (buildEventContext.ProjectContextId == buildData.RestoreContext)
+ {
+ buildData.RestoreContext = null;
+ buildData.RestoreFinished = true;
+ buildData.RestoreFailed = !e.Succeeded;
+ OnRestoreFinished(e, projectData, buildData);
+ }
+
lock (_lock)
{
Terminal.BeginUpdate();
@@ -706,52 +649,26 @@ private void ProjectFinished(object sender, ProjectFinishedEventArgs e)
{
EraseNodes();
- string duration = project.Stopwatch.ElapsedSeconds.ToString("F1");
- ReadOnlyMemory? outputPath = project.OutputPath;
+ string duration = projectData.Stopwatch.ElapsedSeconds.ToString("F1");
+ ReadOnlyMemory? outputPath = projectData.OutputPath;
// Build result. One of 'failed', 'succeeded with warnings', or 'succeeded' depending on the build result and diagnostic messages
// reported during build.
- string buildResult = GetBuildResultString(project.Succeeded, project.ErrorCount, project.WarningCount);
-
- // Check if we're done restoring.
- if (c == _restoreContext)
- {
- if (e.Succeeded)
- {
- if (project.HasErrorsOrWarnings)
- {
- Terminal.WriteLine(ResourceUtilities.FormatResourceStringIgnoreCodeAndKeyword("RestoreCompleteWithMessage",
- buildResult,
- duration));
- }
- else
- {
- Terminal.WriteLine(ResourceUtilities.FormatResourceStringIgnoreCodeAndKeyword("RestoreComplete",
- duration));
- }
- }
- else
- {
- // It will be reported after build finishes.
- _restoreFailed = true;
- }
+ string buildResult = GetBuildResultString(projectData.Succeeded, projectData.ErrorCount, projectData.WarningCount);
- _restoreContext = null;
- _restoreFinished = true;
- }
// If this was a notable project build, we print it as completed only if it's produced an output or warnings/error.
// If this is a test project, print it always, so user can see either a success or failure, otherwise success is hidden
// and it is hard to see if project finished, or did not run at all.
- else if (project.OutputPath is not null || project.BuildMessages is not null || project.IsTestProject)
+ if (projectData.OutputPath is not null || projectData.BuildMessages is not null || projectData.IsTestProject)
{
// Show project build complete and its output
- string projectFinishedHeader = GetProjectFinishedHeader(project, buildResult, duration);
+ string projectFinishedHeader = GetProjectFinishedHeader(projectData, buildResult, duration);
Terminal.Write(projectFinishedHeader);
// Print the output path as a link if we have it.
if (outputPath is { } outputPathSpan)
{
- (var projectDisplayPath, var urlLink) = DetermineOutputPathToRender(outputPathSpan, _initialWorkingDirectory.AsMemory(), project.SourceRoot);
+ (var projectDisplayPath, var urlLink) = DetermineOutputPathToRender(outputPathSpan, _initialWorkingDirectory.AsMemory(), projectData.SourceRoot);
Terminal.WriteLine(ResourceUtilities.FormatResourceStringIgnoreCodeAndKeyword("ProjectFinished_OutputPath", CreateLink(urlLink, projectDisplayPath.ToString())));
}
else
@@ -761,16 +678,20 @@ private void ProjectFinished(object sender, ProjectFinishedEventArgs e)
}
// Print diagnostic output under the Project -> Output line.
- if (project.BuildMessages is not null)
+ if (projectData.BuildMessages is not null)
{
- foreach (TerminalBuildMessage buildMessage in project.BuildMessages)
+ foreach (TerminalBuildMessage buildMessage in projectData.BuildMessages)
{
Terminal.WriteLine($"{DoubleIndentation}{buildMessage.Message}");
}
}
- _buildErrorsCount += project.ErrorCount;
- _buildWarningsCount += project.WarningCount;
+ // Track errors and warnings in build data
+ if (buildData != null)
+ {
+ buildData.BuildErrorsCount += projectData.ErrorCount;
+ buildData.BuildWarningsCount += projectData.WarningCount;
+ }
if (_showNodesDisplay)
{
@@ -785,39 +706,27 @@ private void ProjectFinished(object sender, ProjectFinishedEventArgs e)
}
}
- private void CaptureEvalContext(ProjectEvaluationFinishedEventArgs evalFinish)
+ private void OnRestoreFinished(ProjectFinishedEventArgs e, TerminalProjectInfo? projectData, TerminalBuildData buildData)
{
- var buildEventContext = evalFinish.BuildEventContext;
- if (buildEventContext is null)
+ if (Verbosity > LoggerVerbosity.Quiet && projectData != null)
{
- return;
- }
-
- EvalContext c = new(buildEventContext);
+ string duration = projectData.Stopwatch.ElapsedSeconds.ToString("F1");
+ string buildResult = GetBuildResultString(projectData.Succeeded, projectData.ErrorCount, projectData.WarningCount);
- if (!_evals.TryGetValue(c, out EvalProjectInfo _))
- {
- string? tfm = null;
- string? rid = null;
- foreach (var property in evalFinish.EnumerateProperties())
+ if (e.Succeeded)
{
- if (tfm is not null && rid is not null)
+ if (projectData.HasErrorsOrWarnings)
{
- // We already have both properties, no need to continue.
- break;
+ Terminal.WriteLine(ResourceUtilities.FormatResourceStringIgnoreCodeAndKeyword("RestoreCompleteWithMessage",
+ buildResult,
+ duration));
}
- switch (property.Name)
+ else
{
- case "TargetFramework":
- tfm = property.Value;
- break;
- case "RuntimeIdentifier":
- rid = property.Value;
- break;
+ Terminal.WriteLine(ResourceUtilities.FormatResourceStringIgnoreCodeAndKeyword("RestoreComplete",
+ duration));
}
}
- var evalInfo = new EvalProjectInfo(c, evalFinish.ProjectFile!, tfm, rid);
- _evals[c] = evalInfo;
}
}
@@ -941,123 +850,64 @@ private static string GetProjectFinishedHeader(TerminalProjectInfo project, stri
};
}
- ///
- /// The callback.
- ///
- private void TargetStarted(object sender, TargetStartedEventArgs e)
+ ///
+ protected override void OnTargetStarted(TargetStartedEventArgs e, TerminalProjectInfo projectData, TerminalBuildData buildData)
{
- var buildEventContext = e.BuildEventContext;
- if (_restoreContext is null && buildEventContext is not null && _projects.TryGetValue(new ProjectContext(buildEventContext), out TerminalProjectInfo? project))
+ TerminalNodeStatus? nodeData = CreateNodeData(e, projectData);
+ if (nodeData != null)
{
- project.Stopwatch.Start();
-
- string projectFile = Path.GetFileNameWithoutExtension(e.ProjectFile);
-
- string targetName = e.TargetName;
- if (targetName == CachePluginStartTarget)
- {
- project.IsCachePluginProject = true;
- _hasUsedCache = true;
- }
-
- if (targetName == _testStartTarget)
- {
- targetName = "Testing";
-
- // Use the minimal start time, so if we run tests in parallel, we can calculate duration
- // as this start time, minus time when tests finished.
- _testStartTime = _testStartTime == null
- ? e.Timestamp
- : e.Timestamp < _testStartTime
- ? e.Timestamp : _testStartTime;
- project.IsTestProject = true;
- }
-
- TerminalNodeStatus nodeStatus = new(projectFile, project.TargetFramework, project.RuntimeIdentifier, targetName, project.Stopwatch);
- UpdateNodeStatus(buildEventContext, nodeStatus);
+ StartNode(e, nodeData);
}
}
- private void UpdateNodeStatus(BuildEventContext buildEventContext, TerminalNodeStatus? nodeStatus)
- {
- int nodeIndex = NodeIndexForContext(buildEventContext);
- _nodes[nodeIndex] = nodeStatus;
- }
-
- ///
- /// The callback. Unused.
- ///
- private void TargetFinished(object sender, TargetFinishedEventArgs e)
+ ///
+ protected override void OnTargetFinished(TargetFinishedEventArgs e, TerminalProjectInfo? projectData, TerminalBuildData buildData)
{
// For cache plugin projects which result in a cache hit, ensure the output path is set
// to the item spec corresponding to the GetTargetPath target upon completion.
- var buildEventContext = e.BuildEventContext;
var targetOutputs = e.TargetOutputs;
- if (_restoreContext is not null || buildEventContext is null)
+ if (projectData is null || targetOutputs is null)
{
return;
}
- if (targetOutputs is not null
- && _hasUsedCache
- && e.TargetName == "GetTargetPath"
- && _projects.TryGetValue(new ProjectContext(buildEventContext), out TerminalProjectInfo? project))
+ if (_hasUsedCache && e.TargetName == "GetTargetPath" && projectData.IsCachePluginProject)
{
- if (project is not null && project.IsCachePluginProject)
+ foreach (ITaskItem output in targetOutputs)
{
- foreach (ITaskItem output in targetOutputs)
- {
- project.OutputPath = output.ItemSpec.AsMemory();
- break;
- }
+ projectData.OutputPath = output.ItemSpec.AsMemory();
+ break;
}
}
- else if (targetOutputs is not null
- && e.TargetName == "InitializeSourceRootMappedPaths"
- && _projects.TryGetValue(new ProjectContext(buildEventContext), out project)
- && project.SourceRoot is null)
+ else if (e.TargetName == "InitializeSourceRootMappedPaths" && projectData.SourceRoot is null)
{
- project.SourceRoot =
+ projectData.SourceRoot =
(targetOutputs as IEnumerable)?
.FirstOrDefault(root => !string.IsNullOrEmpty(root.GetMetadata("SourceControl")))
?.ItemSpec.AsMemory();
}
}
- ///
- /// The callback.
- ///
- private void TaskStarted(object sender, TaskStartedEventArgs e)
+ ///
+ protected override void OnTaskStarted(TaskStartedEventArgs e, TerminalProjectInfo projectData, TerminalBuildData buildData)
{
- var buildEventContext = e.BuildEventContext;
- if (_restoreContext is null && buildEventContext is not null && e.TaskName == "MSBuild")
+ if (e.TaskName == "MSBuild")
{
// This will yield the node, so preemptively mark it idle
- UpdateNodeStatus(buildEventContext, null);
+ YieldNode(e);
- if (_projects.TryGetValue(new ProjectContext(buildEventContext), out TerminalProjectInfo? project))
- {
- project.Stopwatch.Stop();
- }
+ projectData.Stopwatch.Stop();
}
}
- ///
- /// The callback.
- ///
- private void MessageRaised(object sender, BuildMessageEventArgs e)
+ ///
+ protected override void OnMessageRaised(BuildMessageEventArgs e, TerminalProjectInfo? projectData, TerminalBuildData buildData)
{
- var buildEventContext = e.BuildEventContext;
- if (buildEventContext is null)
- {
- return;
- }
-
string? message = e.Message;
if (message is not null && e.Importance == MessageImportance.High)
{
- var hasProject = _projects.TryGetValue(new ProjectContext(buildEventContext), out TerminalProjectInfo? project);
+ var hasProject = projectData != null;
// Detect project output path by matching high-importance messages against the "$(MSBuildProjectName) -> ..."
// pattern used by the CopyFilesToOutputDirectory target.
@@ -1066,10 +916,10 @@ private void MessageRaised(object sender, BuildMessageEventArgs e)
{
var projectFileName = Path.GetFileName(e.ProjectFile.AsSpan());
if (!projectFileName.IsEmpty &&
- message.AsSpan().StartsWith(Path.GetFileNameWithoutExtension(projectFileName)) && hasProject)
+ message.AsSpan().StartsWith(Path.GetFileNameWithoutExtension(projectFileName)) && hasProject && projectData != null)
{
ReadOnlyMemory outputPath = e.Message.AsMemory().Slice(index + 4);
- project!.OutputPath = outputPath;
+ projectData.OutputPath = outputPath;
return;
}
}
@@ -1098,10 +948,9 @@ private void MessageRaised(object sender, BuildMessageEventArgs e)
}
}
- if (hasProject && project!.IsTestProject)
+ if (hasProject && projectData != null && projectData.IsTestProject)
{
- var node = _nodes[NodeIndexForContext(buildEventContext)];
-
+ var node = GetNodeForEvent(e);
// Consumes test update messages produced by VSTest and MSTest runner.
if (node != null && e is IExtendedBuildEventArgs extendedMessage)
{
@@ -1112,8 +961,11 @@ private void MessageRaised(object sender, BuildMessageEventArgs e)
var indicator = extendedMessage.ExtendedMetadata!["localizedResult"]!;
var displayName = extendedMessage.ExtendedMetadata!["displayName"]!;
- var status = new TerminalNodeStatus(node.Project, node.TargetFramework, node.RuntimeIdentifier, TerminalColor.Green, indicator, displayName, project.Stopwatch);
- UpdateNodeStatus(buildEventContext, status);
+ var status = new TerminalNodeStatus(node.Project, node.TargetFramework, node.RuntimeIdentifier, TerminalColor.Green, indicator, displayName, projectData.Stopwatch);
+ if (e.BuildEventContext != null)
+ {
+ StartNode(e, status);
+ }
break;
}
@@ -1122,8 +974,11 @@ private void MessageRaised(object sender, BuildMessageEventArgs e)
var indicator = extendedMessage.ExtendedMetadata!["localizedResult"]!;
var displayName = extendedMessage.ExtendedMetadata!["displayName"]!;
- var status = new TerminalNodeStatus(node.Project, node.TargetFramework, node.RuntimeIdentifier, TerminalColor.Yellow, indicator, displayName, project.Stopwatch);
- UpdateNodeStatus(buildEventContext, status);
+ var status = new TerminalNodeStatus(node.Project, node.TargetFramework, node.RuntimeIdentifier, TerminalColor.Yellow, indicator, displayName, projectData.Stopwatch);
+ if (e.BuildEventContext != null)
+ {
+ StartNode(e, status);
+ }
break;
}
@@ -1168,9 +1023,9 @@ private void MessageRaised(object sender, BuildMessageEventArgs e)
return;
}
- if (hasProject)
+ if (hasProject && projectData != null)
{
- project!.AddBuildMessage(TerminalMessageSeverity.Message, FormatInformationalMessage(e));
+ projectData.AddBuildMessage(TerminalMessageSeverity.Message, FormatInformationalMessage(e));
}
else
{
@@ -1194,13 +1049,9 @@ private void MessageRaised(object sender, BuildMessageEventArgs e)
}
}
- ///
- /// The callback.
- ///
- private void WarningRaised(object sender, BuildWarningEventArgs e)
+ ///
+ protected override void OnWarningRaised(BuildWarningEventArgs e, TerminalProjectInfo? projectData, TerminalBuildData buildData)
{
- BuildEventContext? buildEventContext = e.BuildEventContext;
-
// auth provider messages are 'global' in nature and should be a) immediate reported, and b) not re-reported in the summary.
if (IsAuthProviderMessage(e.Message))
{
@@ -1208,9 +1059,7 @@ private void WarningRaised(object sender, BuildWarningEventArgs e)
return;
}
- if (buildEventContext is not null
- && _projects.TryGetValue(new ProjectContext(buildEventContext), out TerminalProjectInfo? project)
- && Verbosity > LoggerVerbosity.Quiet)
+ if (projectData != null && Verbosity > LoggerVerbosity.Quiet)
{
// If the warning is not a 'global' auth provider message, but is immediate, we render it immediately
// but we don't early return so that the project also tracks it.
@@ -1221,17 +1070,17 @@ private void WarningRaised(object sender, BuildWarningEventArgs e)
// This is the general case - _most_ warnings are not immediate, so we add them to the project summary
// and display them in the per-project and final summary.
- project.AddBuildMessage(TerminalMessageSeverity.Warning, FormatWarningMessage(e, TripleIndentation));
+ projectData.AddBuildMessage(TerminalMessageSeverity.Warning, FormatWarningMessage(e, TripleIndentation));
}
else
{
// It is necessary to display warning messages reported by MSBuild,
- // even if it's not tracked in _projects collection or the verbosity is Quiet.
+ // even if it's not tracked in projects collection or the verbosity is Quiet.
// The idea here (similar to the implementation in ErrorRaised) is that
// even in Quiet scenarios we need to show warnings/errors, even if not in
// full project-tree view
RenderImmediateMessage(FormatWarningMessage(e, Indentation));
- _buildWarningsCount++;
+ buildData.BuildWarningsCount++;
}
}
@@ -1286,26 +1135,20 @@ private static bool IsAuthProviderMessage(string? message) =>
}
}
- ///
- /// The callback.
- ///
- private void ErrorRaised(object sender, BuildErrorEventArgs e)
+ ///
+ protected override void OnErrorRaised(BuildErrorEventArgs e, TerminalProjectInfo? projectData, TerminalBuildData buildData)
{
- BuildEventContext? buildEventContext = e.BuildEventContext;
-
- if (buildEventContext is not null
- && _projects.TryGetValue(new ProjectContext(buildEventContext), out TerminalProjectInfo? project)
- && Verbosity > LoggerVerbosity.Quiet)
+ if (projectData != null && Verbosity > LoggerVerbosity.Quiet)
{
- project.AddBuildMessage(TerminalMessageSeverity.Error, FormatErrorMessage(e, TripleIndentation));
+ projectData.AddBuildMessage(TerminalMessageSeverity.Error, FormatErrorMessage(e, TripleIndentation));
}
else
{
- // It is necessary to display error messages reported by MSBuild, even if it's not tracked in _projects collection or the verbosity is Quiet.
+ // It is necessary to display error messages reported by MSBuild, even if it's not tracked in projects collection or the verbosity is Quiet.
// For nicer formatting, any messages from the engine we strip the file portion from.
bool hasMSBuildPlaceholderLocation = e.File.Equals("MSBUILD", StringComparison.Ordinal);
RenderImmediateMessage(FormatErrorMessage(e, Indentation, requireFileAndLinePortion: !hasMSBuildPlaceholderLocation));
- _buildErrorsCount++;
+ buildData.BuildErrorsCount++;
}
}
@@ -1391,6 +1234,35 @@ private void EraseNodes()
#region Helpers
+ private TerminalNodeStatus? GetNodeForEvent(BuildEventArgs e)
+ {
+ var node = GetNodeIdForEvent(e);
+ if (node is int nodeId && _nodes[nodeId] is TerminalNodeStatus status)
+ {
+ return status;
+ }
+
+ return null;
+ }
+
+ private void StartNode(BuildEventArgs e, TerminalNodeStatus status)
+ {
+ var node = GetNodeIdForEvent(e);
+ if (node is int nodeId)
+ {
+ _nodes[nodeId] = status;
+ }
+ }
+
+ public void YieldNode(BuildEventArgs e)
+ {
+ var node = GetNodeIdForEvent(e);
+ if (node is int nodeId)
+ {
+ _nodes[nodeId] = null;
+ }
+ }
+
///
/// Construct a build result summary string.
///
@@ -1437,14 +1309,7 @@ private void RenderImmediateMessage(string message)
}
}
- ///
- /// Returns the index corresponding to the given .
- ///
- private int NodeIndexForContext(BuildEventContext context)
- {
- // Node IDs reported by the build are 1-based.
- return context.NodeId - 1;
- }
+ // NodeIndexForContext is now inherited from base class
///
/// Colorizes the filename part of the given path.
diff --git a/src/Build/Logging/TerminalLogger/TerminalMessageSeverity.cs b/src/Build/Logging/TerminalLogger/TerminalMessageSeverity.cs
index 40fafcea1c6..976db15823f 100644
--- a/src/Build/Logging/TerminalLogger/TerminalMessageSeverity.cs
+++ b/src/Build/Logging/TerminalLogger/TerminalMessageSeverity.cs
@@ -6,4 +6,4 @@ namespace Microsoft.Build.Logging;
///
/// Enumerates the supported message severities.
///
-internal enum TerminalMessageSeverity { Message, Warning, Error }
+public enum TerminalMessageSeverity { Message, Warning, Error }
diff --git a/src/Build/Logging/TerminalLogger/TerminalNodeStatus.cs b/src/Build/Logging/TerminalLogger/TerminalNodeStatus.cs
index dd14f8586ee..eb78f3719e3 100644
--- a/src/Build/Logging/TerminalLogger/TerminalNodeStatus.cs
+++ b/src/Build/Logging/TerminalLogger/TerminalNodeStatus.cs
@@ -11,7 +11,7 @@ namespace Microsoft.Build.Logging;
///
/// Encapsulates the per-node data shown in live node output.
///
-internal class TerminalNodeStatus
+public class TerminalNodeStatus
{
public string Project { get; }
public string? TargetFramework { get; }
diff --git a/src/Build/Logging/TerminalLogger/TerminalProjectInfo.cs b/src/Build/Logging/TerminalLogger/TerminalProjectInfo.cs
index b69c2c515e9..d1b4944e922 100644
--- a/src/Build/Logging/TerminalLogger/TerminalProjectInfo.cs
+++ b/src/Build/Logging/TerminalLogger/TerminalProjectInfo.cs
@@ -10,32 +10,25 @@ namespace Microsoft.Build.Logging;
///
/// A struct containing relevant evaluation-time data that may not be knowable just from ProjectStart events.
///
-///
-///
-///
-///
-internal record struct EvalProjectInfo(TerminalLogger.EvalContext context, string? ProjectFile, string? TargetFramework, string? RuntimeIdentifier)
+public record struct EvalProjectInfo(string? ProjectFile, string? TargetFramework, string? RuntimeIdentifier)
{
- public readonly int Id => context.Id;
}
///
/// Represents a project being built.
///
-internal sealed class TerminalProjectInfo
+public sealed class TerminalProjectInfo
{
private List? _buildMessages;
///
/// Initialized a new with the given .
///
- /// The ProjectContext of this project execution.
/// A subset of the interesting eval-time data for this running project
/// A stopwatch to time the build of the project.
- public TerminalProjectInfo(TerminalLogger.ProjectContext context, EvalProjectInfo evalInfo, StopwatchAbstraction? stopwatch)
+ public TerminalProjectInfo(EvalProjectInfo evalInfo, StopwatchAbstraction? stopwatch)
{
_evalInfo = evalInfo;
- _context = context;
if (stopwatch is not null)
{
@@ -48,11 +41,6 @@ public TerminalProjectInfo(TerminalLogger.ProjectContext context, EvalProjectInf
}
}
- ///
- /// The int value of the ProjectContext id of this project execution.
- ///
- public int Id => _context.Id;
-
///
/// The full path to the project file.
///
@@ -82,7 +70,6 @@ public TerminalProjectInfo(TerminalLogger.ProjectContext context, EvalProjectInf
/// The runtime identifier of the project or null if platform-agnostic.
///
public string? RuntimeIdentifier => _evalInfo.RuntimeIdentifier;
- private readonly TerminalLogger.ProjectContext _context;
private readonly EvalProjectInfo _evalInfo;
///
diff --git a/src/Build/Microsoft.Build.csproj b/src/Build/Microsoft.Build.csproj
index d11075fd13b..df088fb435e 100644
--- a/src/Build/Microsoft.Build.csproj
+++ b/src/Build/Microsoft.Build.csproj
@@ -178,7 +178,9 @@
+
+
diff --git a/src/BuildCheck.UnitTests/EndToEndTests.cs b/src/BuildCheck.UnitTests/EndToEndTests.cs
index d251e8f5ecc..36a6ccad2c3 100644
--- a/src/BuildCheck.UnitTests/EndToEndTests.cs
+++ b/src/BuildCheck.UnitTests/EndToEndTests.cs
@@ -41,6 +41,14 @@ public EndToEndTests(ITestOutputHelper output)
public void Dispose() => _env.Dispose();
+ ///
+ /// Helper method to execute MSBuild with console logger to prevent CI/CD logger auto-detection in test environments
+ ///
+ private string ExecBootstrapedMSBuildWithConsoleLogger(string arguments, out bool success, int timeoutMilliseconds)
+ {
+ return RunnerUtilities.ExecBootstrapedMSBuild($"{arguments} -logger:ConsoleLogger", out success, timeoutMilliseconds: timeoutMilliseconds);
+ }
+
[Theory]
[InlineData(true)]
[InlineData(false)]
@@ -52,7 +60,7 @@ public void PropertiesUsageAnalyzerTest(bool buildInOutOfProcessNode)
out _,
"PropsCheckTest.csproj");
- string output = RunnerUtilities.ExecBootstrapedMSBuild($"{projectFile.Path} -check", out bool success, timeoutMilliseconds: timeoutInMilliseconds);
+ string output = ExecBootstrapedMSBuildWithConsoleLogger($"{projectFile.Path} -check", out bool success, timeoutInMilliseconds);
_env.Output.WriteLine(output);
_env.Output.WriteLine("=========================");
success.ShouldBeTrue(output);
@@ -171,7 +179,7 @@ private EmbedResourceTestOutput RunEmbeddedResourceTest(string resourceXmlToAdd,
_env.SetCurrentDirectory(Path.Combine(workFolder.Path, entryProjectName));
- string output = RunnerUtilities.ExecBootstrapedMSBuild("-check -restore /p:WarnOnCultureOverwritten=True /p:RespectCulture=" + (respectCulture ? "True" : "\"\""), out bool success, timeoutMilliseconds: timeoutInMilliseconds);
+ string output = ExecBootstrapedMSBuildWithConsoleLogger("-check -restore /p:WarnOnCultureOverwritten=True /p:RespectCulture=" + (respectCulture ? "True" : "\"\""), out bool success, timeoutMilliseconds: timeoutInMilliseconds);
_env.Output.WriteLine(output);
_env.Output.WriteLine("=========================");
success.ShouldBeTrue();
@@ -232,7 +240,7 @@ private readonly record struct CopyTestOutput(
private CopyTestOutput RunCopyToOutputTest(bool restore, bool skipUnchangedDuringCopy)
{
- string output = RunnerUtilities.ExecBootstrapedMSBuild($"-check {(restore ? "-restore" : null)} /p:SkipUnchanged={(skipUnchangedDuringCopy ? "True" : "\"\"")}", out bool success, timeoutMilliseconds: timeoutInMilliseconds);
+ string output = ExecBootstrapedMSBuildWithConsoleLogger($"-check {(restore ? "-restore" : null)} /p:SkipUnchanged={(skipUnchangedDuringCopy ? "True" : "\"\"")}", out bool success, timeoutMilliseconds: timeoutInMilliseconds);
_env.Output.WriteLine(output);
_env.Output.WriteLine("=========================");
success.ShouldBeTrue();
@@ -341,7 +349,7 @@ public void WarningsCountExceedsLimitTest(bool buildInOutOfProcessNode, bool lim
_env.SetEnvironmentVariable("MSBUILDDONOTLIMITBUILDCHECKRESULTSNUMBER", "1");
}
- string output = RunnerUtilities.ExecBootstrapedMSBuild($"{projectFile.Path} -check", out bool success, timeoutMilliseconds: timeoutInMilliseconds);
+ string output = ExecBootstrapedMSBuildWithConsoleLogger($"{projectFile.Path} -check", out bool success, timeoutMilliseconds: timeoutInMilliseconds);
_env.Output.WriteLine(output);
_env.Output.WriteLine("=========================");
success.ShouldBeTrue(output);
@@ -383,7 +391,7 @@ public void TFMConfusionCheckTest(string tfmString, string cliSuffix, bool shoul
_env.SetCurrentDirectory(workFolder.Path);
- string output = RunnerUtilities.ExecBootstrapedMSBuild($"-check -restore" + cliSuffix, out bool success, timeoutMilliseconds: timeoutInMilliseconds);
+ string output = ExecBootstrapedMSBuildWithConsoleLogger($"-check -restore" + cliSuffix, out bool success, timeoutMilliseconds: timeoutInMilliseconds);
_env.Output.WriteLine(output);
_env.Output.WriteLine("=========================");
success.ShouldBeTrue();
@@ -465,7 +473,7 @@ public void TFMinNonSdkCheckTest(string projectContent, bool expectCheckTrigger)
_env.SetCurrentDirectory(workFolder.Path);
- string output = RunnerUtilities.ExecBootstrapedMSBuild($"-check -restore", out bool success, timeoutMilliseconds: timeoutInMilliseconds);
+ string output = ExecBootstrapedMSBuildWithConsoleLogger($"-check -restore", out bool success, timeoutMilliseconds: timeoutInMilliseconds);
_env.Output.WriteLine(output);
_env.Output.WriteLine("=========================");
success.ShouldBeTrue();
@@ -488,7 +496,7 @@ public void ConfigChangeReflectedOnReuse()
"PropsCheckTest.csproj");
// Build without BuildCheck - no findings should be reported
- string output = RunnerUtilities.ExecBootstrapedMSBuild($"{projectFile.Path}", out bool success, timeoutMilliseconds: timeoutInMilliseconds);
+ string output = ExecBootstrapedMSBuildWithConsoleLogger($"{projectFile.Path}", out bool success, timeoutMilliseconds: timeoutInMilliseconds);
_env.Output.WriteLine(output);
_env.Output.WriteLine("=========================");
success.ShouldBeTrue(output);
@@ -497,7 +505,7 @@ public void ConfigChangeReflectedOnReuse()
output.ShouldNotContain("BC0203");
// Build with BuildCheck - findings should be reported
- output = RunnerUtilities.ExecBootstrapedMSBuild($"{projectFile.Path} -check", out success, timeoutMilliseconds: timeoutInMilliseconds);
+ output = ExecBootstrapedMSBuildWithConsoleLogger($"{projectFile.Path} -check", out success, timeoutMilliseconds: timeoutInMilliseconds);
_env.Output.WriteLine(output);
_env.Output.WriteLine("=========================");
success.ShouldBeTrue(output);
@@ -516,7 +524,7 @@ public void ConfigChangeReflectedOnReuse()
File.AppendAllText(editorconfigFile.Path, editorConfigChange);
// Build with BuildCheck - findings with new severity should be reported
- output = RunnerUtilities.ExecBootstrapedMSBuild($"{projectFile.Path} -check", out success, timeoutMilliseconds: timeoutInMilliseconds);
+ output = ExecBootstrapedMSBuildWithConsoleLogger($"{projectFile.Path} -check", out success, timeoutMilliseconds: timeoutInMilliseconds);
_env.Output.WriteLine(output);
_env.Output.WriteLine("=========================");
// build should fail due to error checks
@@ -526,7 +534,7 @@ public void ConfigChangeReflectedOnReuse()
output.ShouldContain("error BC0203");
// Build without BuildCheck - no findings should be reported
- output = RunnerUtilities.ExecBootstrapedMSBuild($"{projectFile.Path}", out success, timeoutMilliseconds: timeoutInMilliseconds);
+ output = ExecBootstrapedMSBuildWithConsoleLogger($"{projectFile.Path}", out success, timeoutMilliseconds: timeoutInMilliseconds);
_env.Output.WriteLine(output);
_env.Output.WriteLine("=========================");
success.ShouldBeTrue(output);
@@ -544,7 +552,7 @@ public void SampleCheckIntegrationTest_CheckOnBuild(bool buildInOutOfProcessNode
{
PrepareSampleProjectsAndConfig(buildInOutOfProcessNode, out TransientTestFile projectFile, new List<(string, string)>() { ("BC0101", "warning") });
- string output = RunnerUtilities.ExecBootstrapedMSBuild(
+ string output = ExecBootstrapedMSBuildWithConsoleLogger(
$"{Path.GetFileName(projectFile.Path)} /m:1 -nr:False -restore" +
(checkRequested ? " -check" : string.Empty), out bool success, false, _env.Output, timeoutMilliseconds: timeoutInMilliseconds);
_env.Output.WriteLine(output);
@@ -583,7 +591,7 @@ public void SampleCheckIntegrationTest_ReplayBinaryLogOfCheckedBuild(bool buildI
var projectDirectory = Path.GetDirectoryName(projectFile.Path);
string logFile = _env.ExpectFile(".binlog").Path;
- _ = RunnerUtilities.ExecBootstrapedMSBuild(
+ _ = ExecBootstrapedMSBuildWithConsoleLogger(
$"{Path.GetFileName(projectFile.Path)} /m:1 -nr:False -restore {(checkRequested ? "-check" : string.Empty)} -bl:{logFile}",
out bool success, false, _env.Output, timeoutMilliseconds: timeoutInMilliseconds);
@@ -592,7 +600,7 @@ public void SampleCheckIntegrationTest_ReplayBinaryLogOfCheckedBuild(bool buildI
success.ShouldBeTrue();
}
- string output = RunnerUtilities.ExecBootstrapedMSBuild(
+ string output = ExecBootstrapedMSBuildWithConsoleLogger(
$"{logFile} -flp:logfile={Path.Combine(projectDirectory!, "logFile.log")};verbosity=diagnostic",
out success, false, _env.Output, timeoutMilliseconds: timeoutInMilliseconds);
@@ -634,7 +642,7 @@ public void EditorConfig_SeverityAppliedCorrectly(string BC0101Severity, string?
{
PrepareSampleProjectsAndConfig(true, out TransientTestFile projectFile, new List<(string, string)>() { ("BC0101", BC0101Severity) });
- string output = RunnerUtilities.ExecBootstrapedMSBuild(
+ string output = ExecBootstrapedMSBuildWithConsoleLogger(
$"{Path.GetFileName(projectFile.Path)} /m:1 -nr:False -restore -check",
out bool success, false, _env.Output, timeoutMilliseconds: timeoutInMilliseconds);
@@ -674,7 +682,7 @@ public void CheckHasAccessToAllConfigs()
},
checkCandidatePath));
- string projectCheckBuildLog = RunnerUtilities.ExecBootstrapedMSBuild(
+ string projectCheckBuildLog = ExecBootstrapedMSBuildWithConsoleLogger(
$"{Path.Combine(checkCandidatePath, $"CheckCandidate.csproj")} /m:1 -nr:False -restore -check -verbosity:n", out bool success, timeoutMilliseconds: 1200_0000);
success.ShouldBeTrue();
@@ -697,13 +705,13 @@ public void SampleCheckIntegrationTest_CheckOnBinaryLogReplay(bool buildInOutOfP
string? projectDirectory = Path.GetDirectoryName(projectFile.Path);
string logFile = _env.ExpectFile(".binlog").Path;
- _ = RunnerUtilities.ExecBootstrapedMSBuild(
+ _ = ExecBootstrapedMSBuildWithConsoleLogger(
$"{Path.GetFileName(projectFile.Path)} /m:1 -nr:False -restore -bl:{logFile}",
out bool success, false, _env.Output, timeoutMilliseconds: timeoutInMilliseconds);
success.ShouldBeTrue();
- string output = RunnerUtilities.ExecBootstrapedMSBuild(
+ string output = ExecBootstrapedMSBuildWithConsoleLogger(
$"{logFile} -flp:logfile={Path.Combine(projectDirectory!, "logFile.log")};verbosity=diagnostic {(checkRequested ? "-check" : string.Empty)}",
out success, false, _env.Output, timeoutMilliseconds: timeoutInMilliseconds);
@@ -748,7 +756,7 @@ public void NoEnvironmentVariableProperty_Test(bool? customConfigEnabled, string
new List<(string, string)>() { ("BC0103", "error") },
customConfigData);
- string output = RunnerUtilities.ExecBootstrapedMSBuild(
+ string output = ExecBootstrapedMSBuildWithConsoleLogger(
$"{Path.GetFileName(projectFile.Path)} /m:1 -nr:False -restore -check", out bool success, false, _env.Output, timeoutMilliseconds: timeoutInMilliseconds);
foreach (string expectedMessage in expectedMessages)
@@ -784,7 +792,7 @@ public void NoEnvironmentVariableProperty_Scoping(EvaluationCheckScope scope)
new List<(string, string)>() { ("BC0103", "error") },
customConfigData);
- string output = RunnerUtilities.ExecBootstrapedMSBuild(
+ string output = ExecBootstrapedMSBuildWithConsoleLogger(
$"{Path.GetFileName(projectFile.Path)} /m:1 -nr:False -restore -check", out bool success, false, _env.Output, timeoutMilliseconds: timeoutInMilliseconds);
if (scope == EvaluationCheckScope.ProjectFileOnly)
@@ -808,7 +816,7 @@ public void NoEnvironmentVariableProperty_DeferredProcessing(bool warnAsError, b
out TransientTestFile projectFile,
new List<(string, string)>() { ("BC0103", "warning") });
- string output = RunnerUtilities.ExecBootstrapedMSBuild(
+ string output = ExecBootstrapedMSBuildWithConsoleLogger(
$"{Path.GetFileName(projectFile.Path)} /m:1 -nr:False -restore -check" +
(warnAsError ? " /p:warn2err=BC0103" : "") + (warnAsMessage ? " /p:warn2msg=BC0103" : ""), out bool success,
false, _env.Output, timeoutMilliseconds: timeoutInMilliseconds);
@@ -842,7 +850,7 @@ public void CustomCheckTest_NoEditorConfig(string checkCandidate, string[] expec
var checkCandidatePath = Path.Combine(TestAssetsRootPath, checkCandidate);
AddCustomDataSourceToNugetConfig(checkCandidatePath);
- string projectCheckBuildLog = RunnerUtilities.ExecBootstrapedMSBuild(
+ string projectCheckBuildLog = ExecBootstrapedMSBuildWithConsoleLogger(
$"{Path.Combine(checkCandidatePath, $"{checkCandidate}.csproj")} /m:1 -nr:False -restore -check -verbosity:n",
out bool successBuild, timeoutMilliseconds: timeoutInMilliseconds);
@@ -882,7 +890,7 @@ public void CustomCheckTest_WithEditorConfig(string checkCandidate, string ruleI
ruleToCustomConfig: null,
checkCandidatePath));
- string projectCheckBuildLog = RunnerUtilities.ExecBootstrapedMSBuild(
+ string projectCheckBuildLog = ExecBootstrapedMSBuildWithConsoleLogger(
$"{Path.Combine(checkCandidatePath, $"{checkCandidate}.csproj")} /m:1 -nr:False -restore -check -verbosity:n", out bool _, timeoutMilliseconds: timeoutInMilliseconds);
projectCheckBuildLog.ShouldContain(expectedMessage);
@@ -911,7 +919,7 @@ public void CustomChecksFailGracefully(string ruleId, string friendlyName, strin
ruleToCustomConfig: null,
checkCandidatePath));
- string projectCheckBuildLog = RunnerUtilities.ExecBootstrapedMSBuild(
+ string projectCheckBuildLog = ExecBootstrapedMSBuildWithConsoleLogger(
$"{Path.Combine(checkCandidatePath, $"{checkCandidate}.csproj")} /m:1 -nr:False -restore -check -verbosity:n", out bool success, timeoutMilliseconds: timeoutInMilliseconds);
success.ShouldBeTrue();
@@ -931,7 +939,7 @@ public void DoesNotRunOnRestore(bool buildInOutOfProcessNode)
{
PrepareSampleProjectsAndConfig(buildInOutOfProcessNode, out TransientTestFile projectFile, new List<(string, string)>() { ("BC0101", "warning") });
- string output = RunnerUtilities.ExecBootstrapedMSBuild(
+ string output = ExecBootstrapedMSBuildWithConsoleLogger(
$"{Path.GetFileName(projectFile.Path)} /m:1 -nr:False -t:restore -check",
out bool success, timeoutMilliseconds: timeoutInMilliseconds);
diff --git a/src/Framework/Logging/TerminalColor.cs b/src/Framework/Logging/TerminalColor.cs
index 10e66d8f719..102eacba511 100644
--- a/src/Framework/Logging/TerminalColor.cs
+++ b/src/Framework/Logging/TerminalColor.cs
@@ -6,7 +6,7 @@ namespace Microsoft.Build.Framework.Logging;
///
/// Enumerates the text colors supported by VT100 terminal.
///
-internal enum TerminalColor
+public enum TerminalColor
{
Black = 30,
Red = 31,
diff --git a/src/MSBuild.UnitTests/AzureDevOpsLogger_Tests.cs b/src/MSBuild.UnitTests/AzureDevOpsLogger_Tests.cs
new file mode 100644
index 00000000000..9d7e1475f60
--- /dev/null
+++ b/src/MSBuild.UnitTests/AzureDevOpsLogger_Tests.cs
@@ -0,0 +1,226 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System;
+using System.IO;
+using Microsoft.Build.Logging.CICDLogger.AzureDevOps;
+using Microsoft.Build.Framework;
+using Microsoft.Build.UnitTests.Shared;
+using Shouldly;
+using Xunit;
+
+#nullable enable
+
+namespace Microsoft.Build.UnitTests
+{
+ public class AzureDevOpsLogger_Tests : IEventSource, IDisposable
+ {
+ private readonly StringWriter _outputWriter = new();
+ private readonly AzureDevOpsLogger _logger;
+
+ public AzureDevOpsLogger_Tests()
+ {
+ _logger = new AzureDevOpsLogger();
+ _logger.Initialize(this);
+ }
+
+ public void Dispose()
+ {
+ _logger.Shutdown();
+ _outputWriter.Dispose();
+ }
+
+ [Fact]
+ public void IsEnabled_WhenTFBuildEnvVarIsSet_ReturnsTrue()
+ {
+ using TestEnvironment testEnvironment = TestEnvironment.Create();
+ testEnvironment.SetEnvironmentVariable("TF_BUILD", "True");
+
+ AzureDevOpsLogger.IsEnabled().ShouldBeTrue();
+ }
+
+ [Fact]
+ public void IsEnabled_WhenTFBuildEnvVarIsNotSet_ReturnsFalse()
+ {
+ using TestEnvironment testEnvironment = TestEnvironment.Create();
+ testEnvironment.SetEnvironmentVariable("TF_BUILD", string.Empty);
+
+ AzureDevOpsLogger.IsEnabled().ShouldBeFalse();
+ }
+
+ [Fact]
+ public void ErrorRaised_WithFileAndLineInfo_FormatsCorrectly()
+ {
+ var error = new BuildErrorEventArgs(
+ subcategory: null,
+ code: "CS0103",
+ file: "test.cs",
+ lineNumber: 10,
+ columnNumber: 5,
+ endLineNumber: 10,
+ endColumnNumber: 15,
+ message: "The name 'foo' does not exist in the current context",
+ helpKeyword: null!,
+ senderName: null);
+
+ ErrorRaised?.Invoke(this, error);
+
+ string output = _outputWriter.ToString();
+ output.ShouldContain("##vso[task.logissue type=error;sourcepath=test.cs;linenumber=10;columnnumber=5;code=CS0103]");
+ output.ShouldContain("The name 'foo' does not exist in the current context");
+ }
+
+ [Fact]
+ public void ErrorRaised_WithoutFileInfo_FormatsCorrectly()
+ {
+ var error = new BuildErrorEventArgs(
+ subcategory: null,
+ code: "MSB1234",
+ file: null,
+ lineNumber: 0,
+ columnNumber: 0,
+ endLineNumber: 0,
+ endColumnNumber: 0,
+ message: "General build error",
+ helpKeyword: null!,
+ senderName: null);
+
+ ErrorRaised?.Invoke(this, error);
+
+ string output = _outputWriter.ToString();
+ output.ShouldContain("##vso[task.logissue type=error;code=MSB1234]General build error");
+ }
+
+ [Fact]
+ public void WarningRaised_WithFileAndLineInfo_FormatsCorrectly()
+ {
+ var warning = new BuildWarningEventArgs(
+ subcategory: null,
+ code: "CS0168",
+ file: "test.cs",
+ lineNumber: 20,
+ columnNumber: 8,
+ endLineNumber: 20,
+ endColumnNumber: 12,
+ message: "The variable 'bar' is declared but never used",
+ helpKeyword: null!,
+ senderName: null);
+
+ WarningRaised?.Invoke(this, warning);
+
+ string output = _outputWriter.ToString();
+ output.ShouldContain("##vso[task.logissue type=warning;sourcepath=test.cs;linenumber=20;columnnumber=8;code=CS0168]");
+ output.ShouldContain("The variable 'bar' is declared but never used");
+ }
+
+ [Fact]
+ public void MessageRaised_WithHighImportance_OutputsMessage()
+ {
+ _logger.Verbosity = LoggerVerbosity.Normal;
+
+ var message = new BuildMessageEventArgs(
+ message: "Building project...",
+ helpKeyword: null!,
+ senderName: null,
+ importance: MessageImportance.High);
+
+ MessageRaised?.Invoke(this, message);
+
+ string output = _outputWriter.ToString();
+ output.ShouldContain("Building project...");
+ }
+
+ [Fact]
+ public void ProjectStarted_CreatesSectionCommand()
+ {
+ var projectStarted = new ProjectStartedEventArgs(
+ message: null!,
+ helpKeyword: null!,
+ projectFile: "/src/MyProject.csproj",
+ targetNames: "Build",
+ properties: null!,
+ items: null!);
+
+ ProjectStarted?.Invoke(this, projectStarted);
+
+ string output = _outputWriter.ToString();
+ output.ShouldContain("##[section]Building /src/MyProject.csproj");
+ }
+
+ [Fact]
+ public void BuildFinished_WithSuccess_OutputsSuccessMessage()
+ {
+ _logger.Verbosity = LoggerVerbosity.Minimal;
+
+ var buildFinished = new BuildFinishedEventArgs(
+ message: "Build succeeded",
+ helpKeyword: null!,
+ succeeded: true);
+
+ BuildFinished?.Invoke(this, buildFinished);
+
+ string output = _outputWriter.ToString();
+ output.ShouldContain("Build succeeded.");
+ }
+
+ [Fact]
+ public void BuildFinished_WithFailure_OutputsFailureMessageAndTaskComplete()
+ {
+ _logger.Verbosity = LoggerVerbosity.Minimal;
+
+ var buildFinished = new BuildFinishedEventArgs(
+ message: "Build failed",
+ helpKeyword: null!,
+ succeeded: false);
+
+ BuildFinished?.Invoke(this, buildFinished);
+
+ string output = _outputWriter.ToString();
+ output.ShouldContain("##vso[task.complete result=Failed]Build failed.");
+ }
+
+ [Fact]
+ public void EscapeProperty_HandlesSpecialCharacters()
+ {
+ var error = new BuildErrorEventArgs(
+ subcategory: null,
+ code: "TEST",
+ file: "file;with;semicolons]and]brackets",
+ lineNumber: 1,
+ columnNumber: 1,
+ endLineNumber: 1,
+ endColumnNumber: 1,
+ message: "Test\nwith\nnewlines",
+ helpKeyword: null!,
+ senderName: null);
+
+ ErrorRaised?.Invoke(this, error);
+
+ string output = _outputWriter.ToString();
+ output.ShouldContain("%3B"); // Escaped semicolon
+ output.ShouldContain("%5D"); // Escaped bracket
+ output.ShouldContain("%0A"); // Escaped newline
+ }
+
+ #region IEventSource implementation
+
+#pragma warning disable CS0067
+ public event BuildMessageEventHandler? MessageRaised;
+ public event BuildErrorEventHandler? ErrorRaised;
+ public event BuildWarningEventHandler? WarningRaised;
+ public event BuildStartedEventHandler? BuildStarted;
+ public event BuildFinishedEventHandler? BuildFinished;
+ public event ProjectStartedEventHandler? ProjectStarted;
+ public event ProjectFinishedEventHandler? ProjectFinished;
+ public event TargetStartedEventHandler? TargetStarted;
+ public event TargetFinishedEventHandler? TargetFinished;
+ public event TaskStartedEventHandler? TaskStarted;
+ public event TaskFinishedEventHandler? TaskFinished;
+ public event CustomBuildEventHandler? CustomEventRaised;
+ public event BuildStatusEventHandler? StatusEventRaised;
+ public event AnyEventHandler? AnyEventRaised;
+#pragma warning restore CS0067
+
+ #endregion
+ }
+}
diff --git a/src/MSBuild.UnitTests/GitHubActionsLogger_Tests.cs b/src/MSBuild.UnitTests/GitHubActionsLogger_Tests.cs
new file mode 100644
index 00000000000..dd290eef720
--- /dev/null
+++ b/src/MSBuild.UnitTests/GitHubActionsLogger_Tests.cs
@@ -0,0 +1,265 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System;
+using System.IO;
+using Microsoft.Build.Logging.CICDLogger.GitHubActions;
+using Microsoft.Build.Framework;
+using Microsoft.Build.UnitTests.Shared;
+using Shouldly;
+using Xunit;
+
+#nullable enable
+
+namespace Microsoft.Build.UnitTests
+{
+ public class GitHubActionsLogger_Tests : IEventSource, IDisposable
+ {
+ private readonly StringWriter _outputWriter = new();
+ private readonly GitHubActionsLogger _logger;
+
+ public GitHubActionsLogger_Tests()
+ {
+ _logger = new GitHubActionsLogger();
+ _logger.Initialize(this);
+ }
+
+ public void Dispose()
+ {
+ _logger.Shutdown();
+ _outputWriter.Dispose();
+ }
+
+ [Fact]
+ public void IsEnabled_WhenGitHubActionsEnvVarIsTrue_ReturnsTrue()
+ {
+ using TestEnvironment testEnvironment = TestEnvironment.Create();
+ testEnvironment.SetEnvironmentVariable("GITHUB_ACTIONS", "true");
+
+ GitHubActionsLogger.IsEnabled().ShouldBeTrue();
+ }
+
+ [Fact]
+ public void IsEnabled_WhenGitHubActionsEnvVarIs1_ReturnsTrue()
+ {
+ using TestEnvironment testEnvironment = TestEnvironment.Create();
+ testEnvironment.SetEnvironmentVariable("GITHUB_ACTIONS", "1");
+
+ GitHubActionsLogger.IsEnabled().ShouldBeTrue();
+ }
+
+ [Fact]
+ public void IsEnabled_WhenGitHubActionsEnvVarIsNotSet_ReturnsFalse()
+ {
+ using TestEnvironment testEnvironment = TestEnvironment.Create();
+ testEnvironment.SetEnvironmentVariable("GITHUB_ACTIONS", string.Empty);
+
+ GitHubActionsLogger.IsEnabled().ShouldBeFalse();
+ }
+
+ [Fact]
+ public void ErrorRaised_WithFileAndLineInfo_FormatsCorrectly()
+ {
+ var error = new BuildErrorEventArgs(
+ subcategory: null,
+ code: "CS0103",
+ file: "test.cs",
+ lineNumber: 10,
+ columnNumber: 5,
+ endLineNumber: 10,
+ endColumnNumber: 15,
+ message: "The name 'foo' does not exist in the current context",
+ helpKeyword: null!,
+ senderName: null);
+
+ ErrorRaised?.Invoke(this, error);
+
+ string output = _outputWriter.ToString();
+ output.ShouldContain("::error file=test.cs,line=10,col=5,endColumn=15,title=CS0103::The name 'foo' does not exist in the current context");
+ }
+
+ [Fact]
+ public void ErrorRaised_WithoutFileInfo_FormatsCorrectly()
+ {
+ var error = new BuildErrorEventArgs(
+ subcategory: null,
+ code: "MSB1234",
+ file: null,
+ lineNumber: 0,
+ columnNumber: 0,
+ endLineNumber: 0,
+ endColumnNumber: 0,
+ message: "General build error",
+ helpKeyword: null!,
+ senderName: null);
+
+ ErrorRaised?.Invoke(this, error);
+
+ string output = _outputWriter.ToString();
+ output.ShouldContain("::error,title=MSB1234::General build error");
+ }
+
+ [Fact]
+ public void WarningRaised_WithFileAndLineInfo_FormatsCorrectly()
+ {
+ var warning = new BuildWarningEventArgs(
+ subcategory: null,
+ code: "CS0168",
+ file: "test.cs",
+ lineNumber: 20,
+ columnNumber: 8,
+ endLineNumber: 20,
+ endColumnNumber: 12,
+ message: "The variable 'bar' is declared but never used",
+ helpKeyword: null!,
+ senderName: null);
+
+ WarningRaised?.Invoke(this, warning);
+
+ string output = _outputWriter.ToString();
+ output.ShouldContain("::warning file=test.cs,line=20,col=8,endColumn=12,title=CS0168::The variable 'bar' is declared but never used");
+ }
+
+ [Fact]
+ public void MessageRaised_WithHighImportance_OutputsMessage()
+ {
+ _logger.Verbosity = LoggerVerbosity.Normal;
+
+ var message = new BuildMessageEventArgs(
+ message: "Building project...",
+ helpKeyword: null!,
+ senderName: null,
+ importance: MessageImportance.High);
+
+ MessageRaised?.Invoke(this, message);
+
+ string output = _outputWriter.ToString();
+ output.ShouldContain("Building project...");
+ }
+
+ [Fact]
+ public void MessageRaised_WithLowImportanceAndNormalVerbosity_DoesNotOutput()
+ {
+ _logger.Verbosity = LoggerVerbosity.Normal;
+
+ var message = new BuildMessageEventArgs(
+ message: "Low importance message",
+ helpKeyword: null!,
+ senderName: null,
+ importance: MessageImportance.Low);
+
+ MessageRaised?.Invoke(this, message);
+
+ string output = _outputWriter.ToString();
+ output.ShouldBeEmpty();
+ }
+
+ [Fact]
+ public void ProjectStarted_CreatesGroupCommand()
+ {
+ var projectStarted = new ProjectStartedEventArgs(
+ message: null!,
+ helpKeyword: null!,
+ projectFile: "/src/MyProject.csproj",
+ targetNames: "Build",
+ properties: null!,
+ items: null!);
+
+ ProjectStarted?.Invoke(this, projectStarted);
+
+ string output = _outputWriter.ToString();
+ output.ShouldContain("::group::Building /src/MyProject.csproj");
+ }
+
+ [Fact]
+ public void ProjectFinished_CreatesEndGroupCommand()
+ {
+ var projectFinished = new ProjectFinishedEventArgs(
+ message: null!,
+ helpKeyword: null!,
+ projectFile: "/src/MyProject.csproj",
+ succeeded: true);
+
+ ProjectFinished?.Invoke(this, projectFinished);
+
+ string output = _outputWriter.ToString();
+ output.ShouldContain("::endgroup::");
+ }
+
+ [Fact]
+ public void BuildFinished_WithSuccess_OutputsSuccessMessage()
+ {
+ _logger.Verbosity = LoggerVerbosity.Minimal;
+
+ var buildFinished = new BuildFinishedEventArgs(
+ message: "Build succeeded",
+ helpKeyword: null!,
+ succeeded: true);
+
+ BuildFinished?.Invoke(this, buildFinished);
+
+ string output = _outputWriter.ToString();
+ output.ShouldContain("Build succeeded.");
+ }
+
+ [Fact]
+ public void BuildFinished_WithFailure_OutputsFailureMessage()
+ {
+ _logger.Verbosity = LoggerVerbosity.Minimal;
+
+ var buildFinished = new BuildFinishedEventArgs(
+ message: "Build failed",
+ helpKeyword: null!,
+ succeeded: false);
+
+ BuildFinished?.Invoke(this, buildFinished);
+
+ string output = _outputWriter.ToString();
+ output.ShouldContain("Build failed.");
+ }
+
+ [Fact]
+ public void EscapeProperty_HandlesSpecialCharacters()
+ {
+ var error = new BuildErrorEventArgs(
+ subcategory: null,
+ code: "TEST",
+ file: "file:with:colons,and,commas",
+ lineNumber: 1,
+ columnNumber: 1,
+ endLineNumber: 1,
+ endColumnNumber: 1,
+ message: "Test\nwith\nnewlines",
+ helpKeyword: null!,
+ senderName: null);
+
+ ErrorRaised?.Invoke(this, error);
+
+ string output = _outputWriter.ToString();
+ output.ShouldContain("%3A"); // Escaped colon
+ output.ShouldContain("%2C"); // Escaped comma
+ output.ShouldContain("%0A"); // Escaped newline
+ }
+
+ #region IEventSource implementation
+
+#pragma warning disable CS0067
+ public event BuildMessageEventHandler? MessageRaised;
+ public event BuildErrorEventHandler? ErrorRaised;
+ public event BuildWarningEventHandler? WarningRaised;
+ public event BuildStartedEventHandler? BuildStarted;
+ public event BuildFinishedEventHandler? BuildFinished;
+ public event ProjectStartedEventHandler? ProjectStarted;
+ public event ProjectFinishedEventHandler? ProjectFinished;
+ public event TargetStartedEventHandler? TargetStarted;
+ public event TargetFinishedEventHandler? TargetFinished;
+ public event TaskStartedEventHandler? TaskStarted;
+ public event TaskFinishedEventHandler? TaskFinished;
+ public event CustomBuildEventHandler? CustomEventRaised;
+ public event BuildStatusEventHandler? StatusEventRaised;
+ public event AnyEventHandler? AnyEventRaised;
+#pragma warning restore CS0067
+
+ #endregion
+ }
+}
diff --git a/src/MSBuild.UnitTests/GitLabLogger_Tests.cs b/src/MSBuild.UnitTests/GitLabLogger_Tests.cs
new file mode 100644
index 00000000000..55a2ed12479
--- /dev/null
+++ b/src/MSBuild.UnitTests/GitLabLogger_Tests.cs
@@ -0,0 +1,251 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System;
+using System.IO;
+using Microsoft.Build.Logging.CICDLogger.GitLab;
+using Microsoft.Build.Framework;
+using Microsoft.Build.UnitTests.Shared;
+using Shouldly;
+using Xunit;
+
+#nullable enable
+
+namespace Microsoft.Build.UnitTests
+{
+ public class GitLabLogger_Tests : IEventSource, IDisposable
+ {
+ private readonly StringWriter _outputWriter = new();
+ private readonly GitLabLogger _logger;
+
+ public GitLabLogger_Tests()
+ {
+ _logger = new GitLabLogger();
+ _logger.Initialize(this);
+ }
+
+ public void Dispose()
+ {
+ _logger.Shutdown();
+ _outputWriter.Dispose();
+ }
+
+ [Fact]
+ public void IsEnabled_WhenGitLabCIEnvVarIsSet_ReturnsTrue()
+ {
+ using TestEnvironment testEnvironment = TestEnvironment.Create();
+ testEnvironment.SetEnvironmentVariable("GITLAB_CI", "true");
+
+ GitLabLogger.IsEnabled().ShouldBeTrue();
+ }
+
+ [Fact]
+ public void IsEnabled_WhenGitLabCIEnvVarIsNotSet_ReturnsFalse()
+ {
+ using TestEnvironment testEnvironment = TestEnvironment.Create();
+ testEnvironment.SetEnvironmentVariable("GITLAB_CI", string.Empty);
+
+ GitLabLogger.IsEnabled().ShouldBeFalse();
+ }
+
+ [Fact]
+ public void ErrorRaised_WithFileAndLineInfo_FormatsWithRedColor()
+ {
+ var error = new BuildErrorEventArgs(
+ subcategory: null,
+ code: "CS0103",
+ file: "test.cs",
+ lineNumber: 10,
+ columnNumber: 5,
+ endLineNumber: 10,
+ endColumnNumber: 15,
+ message: "The name 'foo' does not exist in the current context",
+ helpKeyword: null!,
+ senderName: null);
+
+ ErrorRaised?.Invoke(this, error);
+
+ string output = _outputWriter.ToString();
+ output.ShouldContain("\x1b[31m"); // Red color code
+ output.ShouldContain("ERROR:");
+ output.ShouldContain("test.cs(10,5)");
+ output.ShouldContain("CS0103");
+ output.ShouldContain("The name 'foo' does not exist in the current context");
+ output.ShouldContain("\x1b[0m"); // Reset color code
+ }
+
+ [Fact]
+ public void ErrorRaised_WithoutFileInfo_FormatsWithRedColor()
+ {
+ var error = new BuildErrorEventArgs(
+ subcategory: null,
+ code: "MSB1234",
+ file: null,
+ lineNumber: 0,
+ columnNumber: 0,
+ endLineNumber: 0,
+ endColumnNumber: 0,
+ message: "General build error",
+ helpKeyword: null!,
+ senderName: null);
+
+ ErrorRaised?.Invoke(this, error);
+
+ string output = _outputWriter.ToString();
+ output.ShouldContain("\x1b[31m"); // Red color code
+ output.ShouldContain("ERROR:");
+ output.ShouldContain("MSB1234");
+ output.ShouldContain("General build error");
+ output.ShouldContain("\x1b[0m"); // Reset color code
+ }
+
+ [Fact]
+ public void WarningRaised_WithFileAndLineInfo_FormatsWithYellowColor()
+ {
+ var warning = new BuildWarningEventArgs(
+ subcategory: null,
+ code: "CS0168",
+ file: "test.cs",
+ lineNumber: 20,
+ columnNumber: 8,
+ endLineNumber: 20,
+ endColumnNumber: 12,
+ message: "The variable 'bar' is declared but never used",
+ helpKeyword: null!,
+ senderName: null);
+
+ WarningRaised?.Invoke(this, warning);
+
+ string output = _outputWriter.ToString();
+ output.ShouldContain("\x1b[33m"); // Yellow color code
+ output.ShouldContain("WARNING:");
+ output.ShouldContain("test.cs(20,8)");
+ output.ShouldContain("CS0168");
+ output.ShouldContain("The variable 'bar' is declared but never used");
+ output.ShouldContain("\x1b[0m"); // Reset color code
+ }
+
+ [Fact]
+ public void MessageRaised_WithHighImportance_OutputsMessage()
+ {
+ _logger.Verbosity = LoggerVerbosity.Normal;
+
+ var message = new BuildMessageEventArgs(
+ message: "Building project...",
+ helpKeyword: null!,
+ senderName: null,
+ importance: MessageImportance.High);
+
+ MessageRaised?.Invoke(this, message);
+
+ string output = _outputWriter.ToString();
+ output.ShouldContain("Building project...");
+ }
+
+ [Fact]
+ public void ProjectStarted_CreatesCollapsibleSection()
+ {
+ var projectStarted = new ProjectStartedEventArgs(
+ message: null!,
+ helpKeyword: null!,
+ projectFile: "/src/MyProject.csproj",
+ targetNames: "Build",
+ properties: null!,
+ items: null!);
+
+ ProjectStarted?.Invoke(this, projectStarted);
+
+ string output = _outputWriter.ToString();
+ output.ShouldContain("\x1b[0Ksection_start:"); // Section start marker
+ output.ShouldContain("build_project_1"); // Section name
+ output.ShouldContain("\x1b[36m"); // Cyan color
+ output.ShouldContain("Building /src/MyProject.csproj");
+ output.ShouldContain("\x1b[0m"); // Reset color
+ }
+
+ [Fact]
+ public void ProjectFinished_EndsCollapsibleSection()
+ {
+ // Start a project first
+ var projectStarted = new ProjectStartedEventArgs(
+ message: null!,
+ helpKeyword: null!,
+ projectFile: "/src/MyProject.csproj",
+ targetNames: "Build",
+ properties: null!,
+ items: null!);
+ ProjectStarted?.Invoke(this, projectStarted);
+
+ _outputWriter.GetStringBuilder().Clear();
+
+ var projectFinished = new ProjectFinishedEventArgs(
+ message: null!,
+ helpKeyword: null!,
+ projectFile: "/src/MyProject.csproj",
+ succeeded: true);
+
+ ProjectFinished?.Invoke(this, projectFinished);
+
+ string output = _outputWriter.ToString();
+ output.ShouldContain("\x1b[0Ksection_end:"); // Section end marker
+ output.ShouldContain("build_project_1"); // Section name
+ }
+
+ [Fact]
+ public void BuildFinished_WithSuccess_OutputsGreenSuccessMessage()
+ {
+ _logger.Verbosity = LoggerVerbosity.Minimal;
+
+ var buildFinished = new BuildFinishedEventArgs(
+ message: "Build succeeded",
+ helpKeyword: null!,
+ succeeded: true);
+
+ BuildFinished?.Invoke(this, buildFinished);
+
+ string output = _outputWriter.ToString();
+ output.ShouldContain("\x1b[32m"); // Green color code
+ output.ShouldContain("Build succeeded.");
+ output.ShouldContain("\x1b[0m"); // Reset color code
+ }
+
+ [Fact]
+ public void BuildFinished_WithFailure_OutputsRedFailureMessage()
+ {
+ _logger.Verbosity = LoggerVerbosity.Minimal;
+
+ var buildFinished = new BuildFinishedEventArgs(
+ message: "Build failed",
+ helpKeyword: null!,
+ succeeded: false);
+
+ BuildFinished?.Invoke(this, buildFinished);
+
+ string output = _outputWriter.ToString();
+ output.ShouldContain("\x1b[31m"); // Red color code
+ output.ShouldContain("Build failed.");
+ output.ShouldContain("\x1b[0m"); // Reset color code
+ }
+
+ #region IEventSource implementation
+
+#pragma warning disable CS0067
+ public event BuildMessageEventHandler? MessageRaised;
+ public event BuildErrorEventHandler? ErrorRaised;
+ public event BuildWarningEventHandler? WarningRaised;
+ public event BuildStartedEventHandler? BuildStarted;
+ public event BuildFinishedEventHandler? BuildFinished;
+ public event ProjectStartedEventHandler? ProjectStarted;
+ public event ProjectFinishedEventHandler? ProjectFinished;
+ public event TargetStartedEventHandler? TargetStarted;
+ public event TargetFinishedEventHandler? TargetFinished;
+ public event TaskStartedEventHandler? TaskStarted;
+ public event TaskFinishedEventHandler? TaskFinished;
+ public event CustomBuildEventHandler? CustomEventRaised;
+ public event BuildStatusEventHandler? StatusEventRaised;
+ public event AnyEventHandler? AnyEventRaised;
+#pragma warning restore CS0067
+
+ #endregion
+ }
+}
diff --git a/src/MSBuild.UnitTests/MSBuildServer_Tests.cs b/src/MSBuild.UnitTests/MSBuildServer_Tests.cs
index a820d138bf8..9b422a81735 100644
--- a/src/MSBuild.UnitTests/MSBuildServer_Tests.cs
+++ b/src/MSBuild.UnitTests/MSBuildServer_Tests.cs
@@ -99,7 +99,7 @@ public void MSBuildServerTest()
int pidOfServerProcess = ParseNumber(output, "Server ID is ");
pidOfInitialProcess.ShouldNotBe(pidOfServerProcess, "We started a server node to execute the target rather than running it in-proc, so its pid should be different.");
- output = RunnerUtilities.ExecMSBuild(BuildEnvironmentHelper.Instance.CurrentMSBuildExePath, project.Path, out success, false, _output);
+ output = ExecMSBuildWithConsoleLogger(project.Path, out success);
success.ShouldBeTrue();
int newPidOfInitialProcess = ParseNumber(output, "Process ID is ");
newPidOfInitialProcess.ShouldNotBe(pidOfServerProcess, "We started a server node to execute the target rather than running it in-proc, so its pid should be different.");
@@ -125,7 +125,7 @@ public void MSBuildServerTest()
RunnerUtilities.ExecMSBuild(BuildEnvironmentHelper.Instance.CurrentMSBuildExePath, sleepProject.Path, out _);
// Ensure that a new build can still succeed and that its server node is different.
- output = RunnerUtilities.ExecMSBuild(BuildEnvironmentHelper.Instance.CurrentMSBuildExePath, project.Path, out success, false, _output);
+ output = ExecMSBuildWithConsoleLogger(project.Path, out success);
success.ShouldBeTrue();
newPidOfInitialProcess = ParseNumber(output, "Process ID is ");
@@ -152,14 +152,14 @@ public void VerifyMixedLegacyBehavior()
pidOfInitialProcess.ShouldNotBe(pidOfServerProcess, "We started a server node to execute the target rather than running it in-proc, so its pid should be different.");
Environment.SetEnvironmentVariable("MSBUILDUSESERVER", "");
- output = RunnerUtilities.ExecMSBuild(BuildEnvironmentHelper.Instance.CurrentMSBuildExePath, project.Path, out success, false, _output);
+ output = ExecMSBuildWithConsoleLogger(project.Path, out success);
success.ShouldBeTrue();
pidOfInitialProcess = ParseNumber(output, "Process ID is ");
int pidOfNewserverProcess = ParseNumber(output, "Server ID is ");
pidOfInitialProcess.ShouldBe(pidOfNewserverProcess, "We did not start a server node to execute the target, so its pid should be the same.");
Environment.SetEnvironmentVariable("MSBUILDUSESERVER", "1");
- output = RunnerUtilities.ExecMSBuild(BuildEnvironmentHelper.Instance.CurrentMSBuildExePath, project.Path, out success, false, _output);
+ output = ExecMSBuildWithConsoleLogger(project.Path, out success);
success.ShouldBeTrue();
pidOfInitialProcess = ParseNumber(output, "Process ID is ");
pidOfNewserverProcess = ParseNumber(output, "Server ID is ");
@@ -211,13 +211,13 @@ public void BuildsWhileBuildIsRunningOnServer()
Environment.SetEnvironmentVariable("MSBUILDUSESERVER", "0");
- output = RunnerUtilities.ExecMSBuild(BuildEnvironmentHelper.Instance.CurrentMSBuildExePath, project.Path, out success, false, _output);
+ output = ExecMSBuildWithConsoleLogger(project.Path, out success);
success.ShouldBeTrue();
ParseNumber(output, "Server ID is ").ShouldBe(ParseNumber(output, "Process ID is "), "There should not be a server node for this build.");
Environment.SetEnvironmentVariable("MSBUILDUSESERVER", "1");
- output = RunnerUtilities.ExecMSBuild(BuildEnvironmentHelper.Instance.CurrentMSBuildExePath, project.Path, out success, false, _output);
+ output = ExecMSBuildWithConsoleLogger(project.Path, out success);
success.ShouldBeTrue();
pidOfServerProcess.ShouldNotBe(ParseNumber(output, "Server ID is "), "The server should be otherwise occupied.");
pidOfServerProcess.ShouldNotBe(ParseNumber(output, "Process ID is "), "There should not be a server node for this build.");
@@ -347,6 +347,14 @@ public void PropertyMSBuildStartupDirectoryOnServer()
output.ShouldContain($@":MSBuildStartupDirectory:{Environment.CurrentDirectory}:");
}
+ ///
+ /// Execute MSBuild with explicit console logger to prevent CI/CD logger auto-detection in test environments.
+ ///
+ private string ExecMSBuildWithConsoleLogger(string projectPath, out bool success)
+ {
+ return RunnerUtilities.ExecMSBuild(BuildEnvironmentHelper.Instance.CurrentMSBuildExePath, $"{projectPath} -logger:ConsoleLogger", out success, false, _output);
+ }
+
private int ParseNumber(string searchString, string toFind)
{
Regex regex = new(@$"{toFind}(\d+)");
diff --git a/src/MSBuild.UnitTests/PerfLog_Tests.cs b/src/MSBuild.UnitTests/PerfLog_Tests.cs
index eadb3226199..8add4807b64 100644
--- a/src/MSBuild.UnitTests/PerfLog_Tests.cs
+++ b/src/MSBuild.UnitTests/PerfLog_Tests.cs
@@ -40,7 +40,7 @@ public void TestPerfLogEnabledProducedLogFile()
");
string projectPath = Path.Combine(projectFolder.Path, "ClassLibrary.csproj");
- string msbuildParameters = "\"" + projectPath + "\"";
+ string msbuildParameters = "\"" + projectPath + "\" -logger:ConsoleLogger";
RunnerUtilities.ExecMSBuild(msbuildParameters, out bool successfulExit);
successfulExit.ShouldBeTrue();
@@ -76,7 +76,7 @@ public void TestPerfLogDirectoryGetsCreated()
");
string projectPath = Path.Combine(projectFolder.Path, "ClassLibrary.csproj");
- string msbuildParameters = "\"" + projectPath + "\"";
+ string msbuildParameters = "\"" + projectPath + "\" -logger:ConsoleLogger";
Directory.Exists(perfLogPath).ShouldBeFalse();
diff --git a/src/MSBuild.UnitTests/XMake_Tests.cs b/src/MSBuild.UnitTests/XMake_Tests.cs
index f5de4715ccb..947cbd767b3 100644
--- a/src/MSBuild.UnitTests/XMake_Tests.cs
+++ b/src/MSBuild.UnitTests/XMake_Tests.cs
@@ -3006,7 +3006,9 @@ private string ExecuteMSBuildExeExpectFailure(string projectContents, IDictionar
}
}
- string output = RunnerUtilities.ExecMSBuild($"\"{testProject.ProjectFile}\" {string.Join(" ", arguments)}", out var success, _output);
+ // Explicitly use console logger to prevent CI/CD logger auto-detection in test environments
+ string allArguments = $"\"{testProject.ProjectFile}\" -logger:ConsoleLogger {string.Join(" ", arguments)}";
+ string output = RunnerUtilities.ExecMSBuild(allArguments, out var success, _output);
return (success, output);
}
diff --git a/src/MSBuild/XMake.cs b/src/MSBuild/XMake.cs
index e5e12b80ae2..e7f384292ff 100644
--- a/src/MSBuild/XMake.cs
+++ b/src/MSBuild/XMake.cs
@@ -36,6 +36,9 @@
using Microsoft.Build.Shared.Debugging;
using Microsoft.Build.Shared.FileSystem;
using Microsoft.Build.Tasks.AssemblyDependency;
+using Microsoft.Build.Logging.CICDLogger.GitHubActions;
+using Microsoft.Build.Logging.CICDLogger.AzureDevOps;
+using Microsoft.Build.Logging.CICDLogger.GitLab;
using BinaryLogger = Microsoft.Build.Logging.BinaryLogger;
using ConsoleLogger = Microsoft.Build.Logging.ConsoleLogger;
using FileLogger = Microsoft.Build.Logging.FileLogger;
@@ -3823,11 +3826,22 @@ private static ILogger[] ProcessLoggingSwitches(
// Add any loggers which have been specified on the command line
distributedLoggerRecords = ProcessDistributedLoggerSwitch(distributedLoggerSwitchParameters, verbosity);
+ // Check if we should use a CI/CD provider-specific logger
+ bool useCICDLogger = false;
+ if (!useSimpleErrorLogger && !terminalloggerOptIn && !noConsoleLogger)
+ {
+ useCICDLogger = TryProcessCICDLogger(distributedLoggerRecords, verbosity, cpuCount, loggers);
+ }
+
// Otherwise choose default console logger: None, TerminalLogger, or the older ConsoleLogger
if (useSimpleErrorLogger)
{
loggers.Add(new SimpleErrorLogger());
}
+ else if (useCICDLogger)
+ {
+ // CI/CD logger was already processed
+ }
else if (terminalloggerOptIn)
{
ProcessTerminalLogger(noConsoleLogger, aggregatedTerminalLoggerParameters, distributedLoggerRecords, verbosity, cpuCount, loggers);
@@ -4058,6 +4072,63 @@ private static DistributedLoggerRecord CreateTerminalLoggerForwardingLoggerRecor
return new DistributedLoggerRecord(centralLogger, forwardingLoggerDescription);
}
+ ///
+ /// Attempts to detect and configure a CI/CD provider-specific logger.
+ ///
+ /// true if a CI/CD logger was configured; otherwise, false.
+ private static bool TryProcessCICDLogger(
+ List distributedLoggerRecords,
+ LoggerVerbosity verbosity,
+ int cpuCount,
+ List loggers)
+ {
+ INodeLogger cicdLogger = null;
+ Type forwardingLoggerType = null;
+
+ // Check for GitHub Actions
+ if (GitHubActionsLogger.IsEnabled())
+ {
+ cicdLogger = new GitHubActionsLogger { Verbosity = verbosity };
+ forwardingLoggerType = typeof(GitHubActionsForwardingLogger);
+ }
+ // Check for Azure DevOps
+ else if (AzureDevOpsLogger.IsEnabled())
+ {
+ cicdLogger = new AzureDevOpsLogger { Verbosity = verbosity };
+ forwardingLoggerType = typeof(AzureDevOpsForwardingLogger);
+ }
+ // Check for GitLab CI
+ else if (GitLabLogger.IsEnabled())
+ {
+ cicdLogger = new GitLabLogger { Verbosity = verbosity };
+ forwardingLoggerType = typeof(GitLabForwardingLogger);
+ }
+
+ if (cicdLogger != null && forwardingLoggerType != null)
+ {
+ // For single-process builds or when in-proc node is enabled, use the logger directly
+ if (cpuCount == 1 && !Traits.Instance.InProcNodeDisabled)
+ {
+ loggers.Add(cicdLogger);
+ }
+ else
+ {
+ // For multi-process builds, use distributed logger pattern
+ LoggerDescription forwardingLoggerDescription = new LoggerDescription(
+ forwardingLoggerType.FullName,
+ forwardingLoggerType.Assembly.FullName,
+ null,
+ string.Empty,
+ verbosity);
+ distributedLoggerRecords.Add(new DistributedLoggerRecord(cicdLogger, forwardingLoggerDescription));
+ }
+
+ return true;
+ }
+
+ return false;
+ }
+
///
/// Returns a DistributedLoggerRecord containing this logger and a ConfigurableForwardingLogger.
/// Looks at the logger's parameters for any verbosity parameter in order to make sure it is setting up the ConfigurableForwardingLogger