diff --git a/apps/Build.Abstractions/BuildContext.cs b/apps/Build.Abstractions/BuildContext.cs index 96c642e..2a762a0 100644 --- a/apps/Build.Abstractions/BuildContext.cs +++ b/apps/Build.Abstractions/BuildContext.cs @@ -88,6 +88,13 @@ public BuildContext(ICakeContext context) /// public MinVerVersion? Version { get; set; } + /// + /// Gets or sets whether the solution was compiled in this run. + /// When true, later test, pack, and publish steps can reuse outputs + /// without rebuilding project references. + /// + public bool SolutionBuilt { get; set; } + static (DirectoryPath, FilePath) GetRootPath(ICakeContext context, DirectoryPath path) { // Try and find the .git directory. diff --git a/apps/Build.Abstractions/BuildHelpers.cs b/apps/Build.Abstractions/BuildHelpers.cs index 04b6776..c4e6ea8 100644 --- a/apps/Build.Abstractions/BuildHelpers.cs +++ b/apps/Build.Abstractions/BuildHelpers.cs @@ -12,12 +12,11 @@ namespace Build using Cake.Common.IO; using Cake.Common.Tools.DotNet; using Cake.Common.Tools.DotNet.Build; + using Cake.Common.Tools.DotNet.MSBuild; using Cake.Common.Tools.DotNet.Publish; - using Cake.Common.Tools.DotNet.Test; using Cake.Common.Tools.MSBuild; using Cake.Common.Tools.NuGet; using Cake.Common.Tools.NuGet.Restore; - using Cake.Common.Tools.VSTest; using Cake.Common.Xml; using Cake.Core; using Cake.Core.IO; @@ -38,35 +37,44 @@ public static class BuildHelpers }; /// - /// Builds the target project. + /// Builds or publishes the target project. + /// When is set and the project has already been + /// built (for example by a solution-level compile), publish reuses those + /// outputs instead of rebuilding project references. /// /// The build context. /// The project to build. /// States whether to publish the output. /// The output path for publishing. + /// Skip restore when outputs are already restored. + /// Skip building project references. public static void BuildProject( this BuildContext context, BuildProject project, bool publish = false, - DirectoryPath? outputPath = default) + DirectoryPath? outputPath = default, + bool noRestore = false, + bool noDependencies = false) { context = context ?? throw new ArgumentNullException(nameof(context)); project = project ?? throw new ArgumentNullException(nameof(project)); + bool reuseOutputs = project.HasBuilt || noDependencies; + if (project.BuildEngine == BuildEngine.MSBuild) { - // MA - Force a NuGet restore - context.NuGetRestore(project.ProjectFilePath, new NuGetRestoreSettings()); + if (!noRestore) + { + context.NuGetRestore(project.ProjectFilePath, new NuGetRestoreSettings()); + } var settings = new MSBuildSettings() { Configuration = context.Configuration, + MaxCpuCount = 0, ArgumentCustomization = args => { - if (context.Version is not null) - { - args.Append($"/p:SemVer={context.Version}"); - } + SolutionHelpers.ApplyCommonProperties(args, context, noDependencies || reuseOutputs); if (publish && outputPath is not null) { @@ -80,11 +88,6 @@ public static void BuildProject( } } - if (context.SolutionPath is not null) - { - args.Append($"/p:SolutionDir={context.SolutionPath.FullPath}"); - } - return args; } }; @@ -96,60 +99,59 @@ public static void BuildProject( context.MSBuild(project.ProjectFilePath.FullPath, settings); } - else + else if (publish && outputPath is not null) { foreach (string targetFramework in project.TargetFrameworks) { - if (publish && outputPath is not null) - { - var settings = new DotNetPublishSettings() - { - Configuration = context.Configuration, - ArgumentCustomization = args => - { - if (context.Version is not null) - { - args.Append($"/p:SemVer={context.Version}"); - } - - if (context.SolutionPath is not null) - { - args.Append($"/p:SolutionDir={context.SolutionPath.FullPath}"); - } - - return args; - }, - Framework = targetFramework - }; - - context.DotNetPublish(project.ProjectFilePath.FullPath, settings); - } - else + var settings = new DotNetPublishSettings() { - var settings = new DotNetBuildSettings() - { - Configuration = context.Configuration, - ArgumentCustomization = args => - { - if (context.Version is not null) - { - args.Append($"/p:SemVer={context.Version}"); - } - - if (context.SolutionPath is not null) - { - args.Append($"/p:SolutionDir={context.SolutionPath.FullPath}"); - } - - return args; - }, - Framework = targetFramework - }; - - context.DotNetBuild(project.ProjectFilePath.FullPath, settings); - } + Configuration = context.Configuration, + Framework = targetFramework, + OutputDirectory = outputPath, + NoBuild = reuseOutputs, + NoRestore = noRestore || reuseOutputs, + NoDependencies = noDependencies || reuseOutputs, + ArgumentCustomization = args => SolutionHelpers.ApplyCommonProperties(args, context, noDependencies || reuseOutputs) + }; + + context.DotNetPublish(project.ProjectFilePath.FullPath, settings); } } + else + { + var settings = new DotNetBuildSettings() + { + Configuration = context.Configuration, + NoRestore = noRestore, + NoDependencies = noDependencies, + MSBuildSettings = new DotNetMSBuildSettings + { + MaxCpuCount = 0 + }, + ArgumentCustomization = args => SolutionHelpers.ApplyCommonProperties(args, context, noDependencies) + }; + + context.DotNetBuild(project.ProjectFilePath.FullPath, settings); + } + } + + /// + /// Gets discovered projects in the conventional build order: + /// libraries, applications, tests, data, then content. + /// + /// The build context. + /// The ordered project list. + public static IReadOnlyList GetProjectsInBuildOrder(this BuildContext context) + { + context = context ?? throw new ArgumentNullException(nameof(context)); + + var projects = new List(); + projects.AddRange(context.Projects[BuildType.Library]); + projects.AddRange(context.Projects[BuildType.Application]); + projects.AddRange(context.Projects[BuildType.Test]); + projects.AddRange(context.Projects[BuildType.Data]); + projects.AddRange(context.Projects[BuildType.Content]); + return projects; } /// diff --git a/apps/Build.Abstractions/BuildServices.cs b/apps/Build.Abstractions/BuildServices.cs index 42b1984..ebc309b 100644 --- a/apps/Build.Abstractions/BuildServices.cs +++ b/apps/Build.Abstractions/BuildServices.cs @@ -4,7 +4,9 @@ namespace Build { + using System; using System.Collections.Generic; + using System.Linq; using Microsoft.Extensions.DependencyInjection; @@ -13,26 +15,26 @@ namespace Build /// public class BuildServices { - readonly IServiceScopeFactory _serviceScopeFactory; + readonly IServiceProvider _services; /// /// Initialises a new instance of /// - /// The service scope factory. - public BuildServices(IServiceScopeFactory serviceScopeFactory) + /// The root service provider. + public BuildServices(IServiceProvider services) { - _serviceScopeFactory = serviceScopeFactory; + _services = services; } /// - /// Gets the avilable hooks. + /// Gets the available hooks of the requested type. + /// Hooks are discovered from assemblies that reference this build + /// system and registered as implementations. /// /// The hook type. /// The set of hooks. public IEnumerable GetHooks() where THook : ITaskHook - { - yield break; - } + => _services.GetServices().OfType(); } } diff --git a/apps/Build.Abstractions/Hooks/IBuildHook.cs b/apps/Build.Abstractions/Hooks/IBuildHook.cs index d1d06e5..6fa2d8c 100644 --- a/apps/Build.Abstractions/Hooks/IBuildHook.cs +++ b/apps/Build.Abstractions/Hooks/IBuildHook.cs @@ -8,18 +8,23 @@ namespace Build /// /// Defines the required contract for implementing a build hook. + /// BeforeBuild runs for every project before the solution-level compile so + /// extensions can generate sources. AfterBuild runs after compile outputs + /// exist (including leftover projects not in the solution). /// public interface IBuildHook : ITaskHook { /// - /// Executes before the build starts. + /// Executes before the build starts for the given project. + /// Invoked before the solution-level compile. /// /// The build context. /// The build project. void BeforeBuild(BuildContext context, BuildProject project); /// - /// Executes after the build completes. + /// Executes after the build completes for the given project. + /// Invoked after the solution-level compile and any leftover project builds. /// /// The build context. /// The build project. @@ -28,18 +33,22 @@ public interface IBuildHook : ITaskHook /// /// Defines the required contract for implementing an asynchronous build hook. + /// Hook phasing matches : all BeforeBuild callbacks + /// run before compile, all AfterBuild callbacks run after outputs exist. /// public interface IAsyncBuildHook : ITaskHook { /// - /// Executes before the build starts. + /// Executes before the build starts for the given project. + /// Invoked before the solution-level compile. /// /// The build context. /// The build project. Task BeforeBuildAsync(BuildContext context, BuildProject project); /// - /// Executes after the build completes. + /// Executes after the build completes for the given project. + /// Invoked after the solution-level compile and any leftover project builds. /// /// The build context. /// The build project. diff --git a/apps/Build.Abstractions/PackHelpers.cs b/apps/Build.Abstractions/PackHelpers.cs index 41b20fb..00f4044 100644 --- a/apps/Build.Abstractions/PackHelpers.cs +++ b/apps/Build.Abstractions/PackHelpers.cs @@ -61,7 +61,8 @@ public static void PackProject( }, Configuration = context.Configuration, OutputDirectory = context.ArtefactsPath, - NoBuild = project.HasBuilt + NoBuild = project.HasBuilt, + NoRestore = project.HasBuilt }); } } diff --git a/apps/Build.Abstractions/SolutionHelpers.cs b/apps/Build.Abstractions/SolutionHelpers.cs new file mode 100644 index 0000000..a40f531 --- /dev/null +++ b/apps/Build.Abstractions/SolutionHelpers.cs @@ -0,0 +1,137 @@ +// Copyright (c) 2021 Ingenium Software Engineering. All rights reserved. +// This work is licensed under the terms of the MIT license. +// For a copy, see . + +namespace Build +{ + using System; + using System.Collections.Generic; + using System.IO; + using System.Text.RegularExpressions; + + using Cake.Common.Diagnostics; + using Cake.Common.Tools.DotNet; + using Cake.Common.Tools.DotNet.Build; + using Cake.Common.Tools.DotNet.MSBuild; + using Cake.Core; + using Cake.Core.IO; + + using IOPath = System.IO.Path; + + /// + /// Provides helpers for solution-level builds. + /// + public static class SolutionHelpers + { + static readonly Regex SolutionProjectLine = new Regex( + @"^Project\(""[^""]+""\)\s*=\s*""[^""]+"",\s*""([^""]+\.(?:csproj|vbproj|fsproj|sqlproj))""", + RegexOptions.IgnoreCase | RegexOptions.Compiled); + + /// + /// Builds the resolved solution with parallel MSBuild, compiling each + /// project once and building independent projects concurrently. + /// + /// The build context. + /// + /// true when a solution file was found and built; otherwise false. + /// + public static bool TryBuildSolution(this BuildContext context) + { + context = context ?? throw new ArgumentNullException(nameof(context)); + + if (context.SolutionPath is null || !context.FileSystem.Exist(context.SolutionPath)) + { + context.Information("No solution file was resolved; falling back to per-project builds."); + return false; + } + + context.Information("Building solution {0} (parallel MSBuild)", context.SolutionPath.FullPath); + + var settings = new DotNetBuildSettings + { + Configuration = context.Configuration, + MSBuildSettings = new DotNetMSBuildSettings + { + MaxCpuCount = 0 + }, + ArgumentCustomization = args => ApplyCommonProperties(args, context) + }; + + context.DotNetBuild(context.SolutionPath.FullPath, settings); + context.SolutionBuilt = true; + return true; + } + + /// + /// Determines whether the project is included in the resolved solution. + /// + /// The build context. + /// The project. + /// true when the project is listed in the solution. + public static bool IsProjectInSolution(this BuildContext context, BuildProject project) + { + context = context ?? throw new ArgumentNullException(nameof(context)); + project = project ?? throw new ArgumentNullException(nameof(project)); + + return GetSolutionProjectPaths(context).Contains(NormalizePath(project.ProjectFilePath.FullPath)); + } + + /// + /// Gets the set of project file paths listed in the resolved solution. + /// + /// The build context. + /// Normalized full paths of projects in the solution. + public static IReadOnlyCollection GetSolutionProjectPaths(this BuildContext context) + { + context = context ?? throw new ArgumentNullException(nameof(context)); + + var paths = new HashSet(StringComparer.OrdinalIgnoreCase); + if (context.SolutionPath is null || !context.FileSystem.Exist(context.SolutionPath)) + { + return paths; + } + + var solutionDirectory = context.SolutionPath.GetDirectory(); + foreach (string line in File.ReadAllLines(context.SolutionPath.FullPath)) + { + var match = SolutionProjectLine.Match(line); + if (!match.Success) + { + continue; + } + + string relativePath = match.Groups[1].Value.Replace('\\', '/'); + var projectPath = solutionDirectory.CombineWithFilePath(new FilePath(relativePath)); + paths.Add(NormalizePath(projectPath.FullPath)); + } + + return paths; + } + + internal static ProcessArgumentBuilder ApplyCommonProperties( + ProcessArgumentBuilder args, + BuildContext context, + bool noDependencies = false) + { + if (context.Version is not null) + { + args.Append($"/p:SemVer={context.Version}"); + } + + if (context.SolutionPath is not null) + { + args.Append($"/p:SolutionDir={context.SolutionPath.FullPath}"); + } + + if (noDependencies) + { + args.Append("/p:BuildProjectReferences=false"); + } + + return args; + } + + static string NormalizePath(string path) + => IOPath.GetFullPath(path).TrimEnd(IOPath.DirectorySeparatorChar, IOPath.AltDirectorySeparatorChar); + } +} diff --git a/apps/Build.Abstractions/Tasks/BuildProjects.cs b/apps/Build.Abstractions/Tasks/BuildProjects.cs index 3f6c1ac..a35d821 100644 --- a/apps/Build.Abstractions/Tasks/BuildProjects.cs +++ b/apps/Build.Abstractions/Tasks/BuildProjects.cs @@ -5,49 +5,98 @@ namespace Build.Tasks { using System.Collections.Generic; + using System.Linq; + using System.Threading.Tasks; + using Cake.Common.Diagnostics; using Cake.Frosting; /// /// Performs a build of available projects. + /// Prefers a solution-level compile so MSBuild can parallelize independent + /// projects and build each project once. Per-project hooks still run: + /// all / + /// callbacks execute first (so extensions can generate sources), then the + /// solution is built, then leftover projects not in the solution are built + /// without project-reference rebuilds, then AfterBuild hooks run. /// [TaskName("Build")] [IsDependentOn(typeof(ResolveVersion))] [IsDependentOn(typeof(CleanArtefacts))] - public class BuildProjects : BuildTask + public class BuildProjects : AsyncBuildTask { public BuildProjects(BuildServices services) : base(services) { } /// - protected override void RunCore(BuildContext context) + protected override async Task RunCoreAsync(BuildContext context) { - void BuildAvailableProjects(IEnumerable projects) - { - var hooks = Services.GetHooks(); + var projects = context.GetProjectsInBuildOrder(); + var syncHooks = Services.GetHooks().ToList(); + var asyncHooks = Services.GetHooks().ToList(); + + await RunBeforeBuildHooksAsync(context, projects, syncHooks, asyncHooks); + + bool solutionBuilt = context.TryBuildSolution(); - foreach (var project in projects) + foreach (var project in projects) + { + if (!solutionBuilt || !context.IsProjectInSolution(project)) { - foreach (var hook in hooks) - { - hook.BeforeBuild(context, project); - } + context.Information( + "Building {0} {1}", + project.BuildType, + project.Name); + + context.BuildProject( + project, + noRestore: solutionBuilt, + noDependencies: solutionBuilt); + } - context.BuildProject(project); + project.MarkAsBuilt(); + } - foreach (var hook in hooks) - { - hook.AfterBuild(context, project); - } + await RunAfterBuildHooksAsync(context, projects, syncHooks, asyncHooks); + } - project.MarkAsBuilt(); + static async Task RunBeforeBuildHooksAsync( + BuildContext context, + IReadOnlyList projects, + IReadOnlyCollection syncHooks, + IReadOnlyCollection asyncHooks) + { + foreach (var project in projects) + { + foreach (var hook in syncHooks) + { + hook.BeforeBuild(context, project); + } + + foreach (var hook in asyncHooks) + { + await hook.BeforeBuildAsync(context, project); } } + } - BuildAvailableProjects(context.Projects[BuildType.Library]); - BuildAvailableProjects(context.Projects[BuildType.Application]); - BuildAvailableProjects(context.Projects[BuildType.Test]); - BuildAvailableProjects(context.Projects[BuildType.Data]); - BuildAvailableProjects(context.Projects[BuildType.Content]); + static async Task RunAfterBuildHooksAsync( + BuildContext context, + IReadOnlyList projects, + IReadOnlyCollection syncHooks, + IReadOnlyCollection asyncHooks) + { + foreach (var project in projects) + { + foreach (var hook in syncHooks) + { + hook.AfterBuild(context, project); + } + + foreach (var hook in asyncHooks) + { + await hook.AfterBuildAsync(context, project); + } + } } } } diff --git a/apps/Build.Abstractions/Tasks/PackProjects.cs b/apps/Build.Abstractions/Tasks/PackProjects.cs index 4aa6fec..7c78659 100644 --- a/apps/Build.Abstractions/Tasks/PackProjects.cs +++ b/apps/Build.Abstractions/Tasks/PackProjects.cs @@ -8,8 +8,10 @@ namespace Build.Tasks /// /// Packs any available apps and libraries. + /// Depends on Build so pack can pass --no-build and reuse outputs. /// [TaskName("Pack")] + [IsDependentOn(typeof(BuildProjects))] public class PackProjects : BuildTask { public PackProjects(BuildServices services) : base(services) { } diff --git a/apps/Build.Abstractions/Tasks/TestProjects.cs b/apps/Build.Abstractions/Tasks/TestProjects.cs index 03a0346..9be8100 100644 --- a/apps/Build.Abstractions/Tasks/TestProjects.cs +++ b/apps/Build.Abstractions/Tasks/TestProjects.cs @@ -4,12 +4,18 @@ namespace Build.Tasks { + using System.Linq; + using Cake.Frosting; /// /// Executes any unit tests for available test projects. + /// After a solution-level build, SDK test projects in the solution are + /// run with dotnet test on the solution and --no-build. + /// Projects outside the solution or using MSBuild are still tested individually. /// [TaskName("Test")] + [IsDependentOn(typeof(BuildProjects))] public class TestProjects : BuildTask { public TestProjects(BuildServices services) : base(services) { } @@ -17,8 +23,35 @@ public TestProjects(BuildServices services) : base(services) { } /// protected override void RunCore(BuildContext context) { - foreach (var project in context.Projects[BuildType.Test]) + var tests = context.Projects[BuildType.Test]; + if (tests.Count == 0) { + return; + } + + bool solutionTested = false; + if (context.SolutionBuilt) + { + bool hasSdkTestsInSolution = tests.Any(project => + project.BuildEngine == BuildEngine.DotNetSdk && + context.IsProjectInSolution(project)); + + if (hasSdkTestsInSolution) + { + context.TestSolution(); + solutionTested = true; + } + } + + foreach (var project in tests) + { + if (solutionTested && + project.BuildEngine == BuildEngine.DotNetSdk && + context.IsProjectInSolution(project)) + { + continue; + } + context.TestProject(project); } } diff --git a/apps/Build.Abstractions/TestHelpers.cs b/apps/Build.Abstractions/TestHelpers.cs index bfa682d..2e14d48 100644 --- a/apps/Build.Abstractions/TestHelpers.cs +++ b/apps/Build.Abstractions/TestHelpers.cs @@ -21,6 +21,40 @@ namespace Build /// public static class TestHelpers { + /// + /// Runs tests for every SDK test project in the solution without rebuilding. + /// + /// The build context. + public static void TestSolution(this BuildContext context) + { + string coverletRunSettingsFile = GetCoverletRunSettingsPath(context); + + var settings = new DotNetTestSettings + { + NoBuild = true, + NoRestore = true, + Configuration = context.Configuration, + ResultsDirectory = context.TestResultsPath, + Loggers = new[] + { + "trx" + }, + ArgumentCustomization = args => + { + args.Append("--collect:\"XPlat Code Coverage\""); + args.Append($"--settings {coverletRunSettingsFile}"); + if (context.SolutionPath is not null) + { + args.Append($"/p:SolutionDir={context.SolutionPath.FullPath}"); + } + + return args; + } + }; + + context.DotNetTest(context.SolutionPath.FullPath, settings); + } + /// /// Tests the target project. /// @@ -32,12 +66,14 @@ public static void TestProject( { string name = project.ProjectFilePath.GetFilenameWithoutExtension().Segments.Last(); string resultsFile = $"{name}.xml"; - string coverletFile = $"{name}-coverage"; - string coverletRunSettingsFile = FilePath.FromString("./coverlet.runsettings").MakeAbsolute(context.BuildPath).FullPath; + string coverletRunSettingsFile = GetCoverletRunSettingsPath(context); if (project.BuildEngine == BuildEngine.MSBuild) { - context.BuildProject(project); + if (!project.HasBuilt) + { + context.BuildProject(project); + } resultsFile = FilePath.FromString($"./{resultsFile}").MakeAbsolute(context.TestResultsPath).FullPath; @@ -55,11 +91,11 @@ public static void TestProject( foreach (string framework in project.TargetFrameworks) { resultsFile = $"{name}-{framework}.xml"; - coverletFile = $"{name}-{framework}-coverage"; var settings = new DotNetTestSettings { NoBuild = project.HasBuilt, + NoRestore = project.HasBuilt, Configuration = context.Configuration, Framework = framework, ResultsDirectory = context.TestResultsPath, @@ -71,7 +107,11 @@ public static void TestProject( { args.Append("--collect:\"XPlat Code Coverage\""); args.Append($"--settings {coverletRunSettingsFile}"); - args.Append($"/p:SolutionDir={context.SolutionPath.FullPath}"); + if (context.SolutionPath is not null) + { + args.Append($"/p:SolutionDir={context.SolutionPath.FullPath}"); + } + return args; } }; @@ -81,6 +121,9 @@ public static void TestProject( } } + static string GetCoverletRunSettingsPath(BuildContext context) + => FilePath.FromString("./coverlet.runsettings").MakeAbsolute(context.BuildPath).FullPath; + static DirectoryPath? GetTestAdapterLocation(BuildContext context, BuildProject project) { if (project.TestFramework == TestFramework.NUnit)