Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 69 additions & 31 deletions .build/definitions.cake
Original file line number Diff line number Diff line change
@@ -1,21 +1,35 @@
// ADDINS
#addin nuget:?package=Cake.Coveralls&version=4.0.0
#addin nuget:?package=Cake.FileHelpers&version=7.0.0
#addin nuget:?package=Cake.AppVeyor&version=6.0.0
#addin nuget:?package=Cake.Coverlet&version=6.0.1
#addin nuget:?package=Cake.FileHelpers&version=9.0.0
#addin nuget:?package=Cake.AppVeyor&version=10.0.0

// TOOLS
#tool nuget:?package=GitReleaseManager&version=0.20.0
#tool nuget:?package=GitVersion.CommandLine&version=5.12.0
#tool nuget:?package=OpenCover&version=4.7.1221
#tool nuget:?package=GitReleaseManager.Tool&version=0.20.0
#tool "dotnet:?package=GitVersion.Tool&version=6.0.0"
#tool nuget:?package=ReportGenerator&version=5.4.8


public class CodeCoverageSettings
{
public string ExcludeByFile { get; set; } = "*/*Designer.cs";
public string ExcludeByAttribute { get; set; } = "*.ExcludeFromCodeCoverage*";
public string ExcludeFilter { get; set; } = "-[Tests*]*;-[*]Microsoft.CodeAnalysis*;-[*]System.Runtime.CompilerServices.*";
public string IncludeFilter { get; set; }
/// <summary>
/// Glob pattern to exclude source files from coverage (Coverlet format, e.g. "**/*Designer.cs").
/// </summary>
public string ExcludeByFile { get; set; } = "**/*Designer.cs";

/// <summary>
/// Attribute short name used to exclude members from coverage (e.g. "ExcludeFromCodeCoverage").
/// </summary>
public string ExcludeByAttribute { get; set; } = "ExcludeFromCodeCoverage";

/// <summary>
/// Coverlet exclude filters in [Assembly]Type format, e.g. "[Tests*]*".
/// </summary>
public List<string> ExcludeFilter { get; set; } = new List<string> { "[Tests*]*", "[*]Microsoft.CodeAnalysis*", "[*]System.Runtime.CompilerServices.*" };

/// <summary>
/// Coverlet include filters in [Assembly]Type format, e.g. "[SharpArch.*]*".
/// </summary>
public List<string> IncludeFilter { get; set; } = new List<string>();
}

// params
Expand All @@ -40,7 +54,7 @@ public class ProjectSettings {
SolutionName = solutionName;

CodeCoverage = new CodeCoverageSettings {
IncludeFilter = $"+[solutionName*]*"
IncludeFilter = new List<string> { $"[{solutionName}*]*" }
};
}
}
Expand Down Expand Up @@ -101,25 +115,26 @@ public class Paths {
public DirectoryPath RootDir { get; }
public string SrcDir { get; set; }
public string ArtifactsDir { get; set; }
public string TestCoverageOutputFile { get; set; }
/// <summary>Glob matching the per-test-project, per-TargetFramework Cobertura XML files Coverlet writes
/// (e.g. coverage.SharpArch.XunitTests.net10.0.cobertura.xml). Each test project is run separately with a
/// unique CoverletOutputName so projects/TFMs don't overwrite each other. Consumed both by ReportGenerator
/// (local HTML report) and by the Coveralls upload loop.</summary>
public string TestCoverageGlobPattern { get; set; }
public string TestCoverageReportDir { get; set; }
public string PackagesDir { get; set; }
public string BuildPropsFile { get; set; }
public string TestsRootDir { get; set; }
public string SamplesRootDir { get; set; }
public string CommonAssemblyVersionFile { get; set; }

public Paths(ICakeContext context)
{
RootDir = context.MakeAbsolute(context.Directory("./"));
SrcDir = RootDir.Combine("src").ToString();
SrcDir = RootDir.Combine("Src").ToString();
ArtifactsDir = RootDir.Combine("artifacts").ToString();
TestCoverageOutputFile = ArtifactsDir + "/OpenCover.xml";
TestCoverageGlobPattern = ArtifactsDir + "/coverage.*.cobertura.*.xml";
TestCoverageReportDir = ArtifactsDir + "/CodeCoverageReport";
PackagesDir = ArtifactsDir + "/packages";
BuildPropsFile = SrcDir + "/Directory.Build.props";
TestsRootDir = SrcDir + "/tests";
CommonAssemblyVersionFile = SrcDir + "/common/AssemblyVersion.cs";
CommonAssemblyVersionFile = SrcDir + "/Common/AssemblyVersion.cs";
}

}
Expand All @@ -133,6 +148,9 @@ public class BuildInfo {
public bool IsRelease {get; protected set;}

public bool IsLocal { get; protected set; }

public bool IsPullRequest { get; protected set; }

public string AppVeyorJobId { get; protected set; }

public BuildVersion Version { get; protected set; }
Expand All @@ -151,18 +169,39 @@ public class BuildInfo {
throw new ArgumentNullException(nameof(context));
var target = context.Argument("target", "Default");
var config = context.Argument("buildConfig", "Release");
var buildSystem = context.BuildSystem();

// Calculate version and commit hash
GitVersion semVersion = context.GitVersion();
var version = new BuildVersion(
semVersion.NuGetVersion,
semVersion.FullBuildMetaData,
semVersion.InformationalVersion,
$"{semVersion.Major+1}.0.0",
semVersion.Sha,
semVersion.MajorMinorPatch
);
var buildSystem = context.BuildSystem();
var repositoryInfo = RepositoryInfo.Get(buildSystem, settings);
BuildVersion version;

if (repositoryInfo.IsPullRequest) {
// GitVersion fails on PR builds, use 0.PullRequestId.BuildNumber as a version number
var buildVersion = $"0.{buildSystem.AppVeyor.Environment.PullRequest.Number}.{buildSystem.AppVeyor.Environment.Build.Number}";
var commitHash = buildSystem.AppVeyor.Environment.Repository.Commit.Id;

version = new BuildVersion(
buildVersion,
$"{buildVersion}/{commitHash}-PR-{buildSystem.AppVeyor.Environment.PullRequest.Title}",
buildVersion,
$"{buildVersion}.0",
commitHash,
buildVersion
);
}
else {
// Calculate version and commit hash
// GitVersion 6.0 removed the NuGetVersion variable; SemVer is its SemVer2-compatible
// replacement and is accepted by modern NuGet (v3+).
GitVersion semVersion = context.GitVersion();
version = new BuildVersion(
semVersion.SemVer,
semVersion.FullBuildMetaData,
semVersion.InformationalVersion,
$"{semVersion.Major+1}.0.0",
semVersion.Sha,
semVersion.MajorMinorPatch
);
}

var gitHubToken = context.EnvironmentVariable("GITHUB_TOKEN");

Expand All @@ -172,6 +211,7 @@ public class BuildInfo {
IsDebug = string.Equals(config, "Debug", StringComparison.OrdinalIgnoreCase),
IsRelease = string.Equals(config, "Release", StringComparison.OrdinalIgnoreCase),
IsLocal = buildSystem.IsLocalBuild,
IsPullRequest = repositoryInfo.IsPullRequest,
AppVeyorJobId = buildSystem.AppVeyor.Environment.JobId,
Version = version,
Repository = RepositoryInfo.Get(buildSystem, settings),
Expand All @@ -181,5 +221,3 @@ public class BuildInfo {
};
}
}


187 changes: 133 additions & 54 deletions .build/tasks.cake
Original file line number Diff line number Diff line change
Expand Up @@ -39,71 +39,77 @@ Task("Restore")
Task("RunXunitTests")
.Does<BuildInfo>(build =>
{
var projectPath = build.Paths.SrcDir;
var projectFilename = build.Settings.SolutionName;
// keep in sync with src/Directory.Build.props
var solutionFullPath = new DirectoryPath(build.Paths.SrcDir).Combine(build.Settings.SolutionName) + ".sln";

// Build DotNetTestSettings for a given configuration and log-file name.
DotNetTestSettings BuildTestSettings(string buildCfg, string logFilename)
{
Func<string,ProcessArgumentBuilder> buildProcessArgs = (buildCfg) => {
var pb = new ProcessArgumentBuilder()
.AppendSwitch("--configuration", buildCfg)
.AppendSwitch("--filter", "Category!=ManualTests")
.AppendSwitch("--results-directory", build.Paths.ArtifactsDir)
.Append("--no-restore")
.Append("--no-build");
if (!build.IsLocal) {
pb.AppendSwitch("--test-adapter-path", ".")
.AppendSwitch("--logger", "AppVeyor");
}
else {
pb.AppendSwitch("--logger", $"trx;LogFileName={projectFilename}.trx");
var ts = new DotNetTestSettings
{
Configuration = buildCfg,
Filter = "Category!=ManualTests",
ResultsDirectory = new DirectoryPath(build.Paths.ArtifactsDir),
NoRestore = true,
NoBuild = true,
ArgumentCustomization = args =>
{
if (!build.IsLocal)
return args.AppendSwitch("--test-adapter-path", ".")
.AppendSwitch("--logger", "AppVeyor");
return args.AppendSwitch("--logger", $"trx;LogFileName={logFilename}.trx");
}
return pb;
};
return ts;
}

Information("Calculating code coverage for {0} ...", projectFilename);
// Coverlet's MSBuild integration runs per test project. Testing the whole solution in one call makes
// every project write to the same CoverletOutputDirectory with the same CoverletOutputName, so each TFM
// slot is overwritten by the *last* project that runs and only that project's coverage survives. Instead,
// test each project individually with a unique CoverletOutputName; Coverlet still appends the TFM for
// multi-targeted projects, producing files like coverage.<Project>.<TFM>.cobertura.xml. UploadCoverage
// then sends each file to Coveralls as a distinct parallel job so the full SharpArch.* surface is covered
// instead of just the last project's slice.
var testProjects = GetFiles($"{build.Paths.SrcDir}/**/*.csproj")
.Where(f => f.GetFilenameWithoutExtension().ToString().Contains("Tests"))
.OrderBy(f => f.GetFilenameWithoutExtension().ToString())
.ToList();

var openCoverSettings = new OpenCoverSettings
foreach (var project in testProjects)
{
var projectName = project.GetFilenameWithoutExtension().ToString();
var coverletSettings = new CoverletSettings
{
OldStyle = true,
ReturnTargetCodeOffset = 0,
ArgumentCustomization = args => args.Append("-mergeoutput").Append("-hideskipped:File;Filter;Attribute"),
WorkingDirectory = projectPath,
}
.WithFilter($"{build.Settings.CodeCoverage.IncludeFilter} {build.Settings.CodeCoverage.ExcludeFilter}")
.ExcludeByAttribute(build.Settings.CodeCoverage.ExcludeByAttribute)
.ExcludeByFile(build.Settings.CodeCoverage.ExcludeByFile);

// run open cover for debug build configuration
OpenCover(
tool => tool.DotNetTool(
projectPath.ToString(),
"test",
buildProcessArgs("Debug")
),
build.Paths.TestCoverageOutputFile,
openCoverSettings
CollectCoverage = true,
CoverletOutputFormat = CoverletOutputFormat.cobertura,
CoverletOutputDirectory = new DirectoryPath(build.Paths.ArtifactsDir),
CoverletOutputName = $"coverage.{projectName}.cobertura.xml",
ExcludeByFile = new List<string> { build.Settings.CodeCoverage.ExcludeByFile },
ExcludeByAttribute = new List<string> { build.Settings.CodeCoverage.ExcludeByAttribute },
Include = build.Settings.CodeCoverage.IncludeFilter,
Exclude = build.Settings.CodeCoverage.ExcludeFilter,
};

Information("Calculating code coverage for {0} ...", projectName);
DotNetTest(
project.FullPath,
BuildTestSettings("Debug", projectName),
coverletSettings
);
}

// run tests again if Release mode was requested
if (build.IsRelease)
{
Information("Running Release mode tests for {0} ...", projectFilename);
DotNetTool(
solutionFullPath,
"test",
buildProcessArgs("Release")
);
}
// Run Release-mode tests (no coverage) when a Release build was requested.
if (build.IsRelease)
{
Information("Running Release mode tests for {0} ...", build.Settings.SolutionName);
DotNetTest(solutionFullPath, BuildTestSettings("Release", build.Settings.SolutionName));
}
})
.DeferOnError();

Task("CleanPreviousTestResults")
.Does<BuildInfo>(build =>
{
if (FileExists(build.Paths.TestCoverageOutputFile))
DeleteFile(build.Paths.TestCoverageOutputFile);
DeleteFiles(build.Paths.ArtifactsDir + "/coverage.*.cobertura.xml");
DeleteFiles(build.Paths.ArtifactsDir + "/*.trx");
if (DirectoryExists(build.Paths.TestCoverageReportDir))
DeleteDirectory(build.Paths.TestCoverageReportDir, new DeleteDirectorySettings
Expand All @@ -117,17 +123,90 @@ Task("GenerateCoverageReport")
.WithCriteria<BuildInfo>((ctx, build) => build.IsLocal)
.Does<BuildInfo>(build =>
{
ReportGenerator((FilePath)build.Paths.TestCoverageOutputFile, build.Paths.TestCoverageReportDir);
ReportGenerator(new GlobPattern(build.Paths.TestCoverageGlobPattern), build.Paths.TestCoverageReportDir);
});

Task("UploadCoverage")
.WithCriteria<BuildInfo>((ctx, build) => !build.IsLocal)
.WithCriteria<BuildInfo>((ctx, build) => build.IsPullRequest == false && build.IsLocal == false)
.Does<BuildInfo>(build =>
{
CoverallsNet(build.Paths.TestCoverageOutputFile, CoverallsNetReportType.OpenCover, new CoverallsNetSettings()
// Uses the self-contained coveralls-windows.exe "coverage-reporter" binary (no .NET runtime
// dependency) instead of the abandoned, .NET-6-only coveralls.net/Cake.Coveralls tool.
//
// Each per-project/per-TFM Cobertura file is uploaded as a DISTINCT parallel job. The
// coverage-reporter does NOT merge overlapping coverage within a single job (its
// SourceFiles.add just concatenates source-file entries), so a single multi-file `report` —
// or several `report` calls sharing the auto-detected AppVeyor service_job_id — would
// concatenate and lose data. Distinct --job-id per upload makes Coveralls treat each file as
// a separate parallel job; the final `coveralls done` fires the completion webhook that
// aggregates all jobs for this build (service_number, auto-detected from AppVeyor) with
// server-side per-line-max merging. `done` MUST run even when individual uploads fail,
// otherwise Coveralls never finalizes the merge — hence the try/finally.
var reporterExe = EnvironmentVariable("COVERALLS_REPORTER_EXE") ?? "coveralls-windows.exe";
var buildNumber = EnvironmentVariable("APPVEYOR_BUILD_NUMBER");

var coverageFiles = GetFiles(build.Paths.TestCoverageGlobPattern)
.OrderBy(f => f.GetFilename().ToString())
.ToList();

if (coverageFiles.Count == 0)
{
RepoTokenVariable = "COVERALLS_REPO_TOKEN"
});
Warning("No coverage files found matching {0}; skipping Coveralls upload.", build.Paths.TestCoverageGlobPattern);
return;
}

var failures = new List<string>();
var uploaded = 0;
try
{
foreach (var file in coverageFiles)
{
// Distinct job id per project+TFM (prefixed with the build number for cross-build
// uniqueness) so each upload is a separate parallel job rather than overwriting the
// auto-detected AppVeyor service_job_id that all calls in this job share.
var jobId = string.IsNullOrEmpty(buildNumber)
? file.GetFilenameWithoutExtension().ToString()
: $"{buildNumber}-{file.GetFilenameWithoutExtension()}";

Information("Uploading coverage {0} to Coveralls (parallel job {1}) ...", file.GetFilename(), jobId);

var exitCode = StartProcess(reporterExe, new ProcessSettings
{
Arguments = new ProcessArgumentBuilder()
.Append("report")
.AppendQuoted(file.FullPath)
.Append("--format=cobertura")
.Append("--parallel")
.Append($"--job-id={jobId}")
.Append("--no-logo")
});

if (exitCode != 0)
failures.Add($"{file.GetFilename()} (exit code {exitCode})");
else
uploaded++;
}
}
finally
{
// Fire the completion webhook so Coveralls aggregates the parallel jobs for this build,
// regardless of whether some uploads failed. Only finalize if at least one job was sent.
if (uploaded > 0)
{
Information("Finalizing Coveralls parallel build ...");
var doneExitCode = StartProcess(reporterExe, new ProcessSettings
{
Arguments = new ProcessArgumentBuilder()
.Append("done")
.Append("--no-logo")
});
if (doneExitCode != 0)
failures.Add($"coveralls done (exit code {doneExitCode})");
}
}

if (failures.Count > 0)
throw new Exception("Coveralls upload reported failures:\n" + string.Join("\n", failures));
});

Task("RunUnitTests")
Expand Down
Loading