using System; using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; using System.Runtime.InteropServices; using System.Text.RegularExpressions; using System.Threading.Tasks; using System.Xml.Linq; using GlobExpressions; using static Bullseye.Targets; using static SimpleExec.Command; const string Clean = "clean"; const string Restore = "restore"; const string Build = "build"; const string Test = "test"; const string Format = "format"; const string CheckFormat = "check-format"; const string Publish = "publish"; const string DetermineVersion = "determine-version"; const string UpdateVersion = "update-version"; const string PushToNuGet = "push-to-nuget"; const string DisplayBenchmarkResults = "display-benchmark-results"; const string CompareBenchmarkResults = "compare-benchmark-results"; const string GenerateBaseline = "generate-baseline"; const string CliNative = "cli-native"; const string DetermineCliVersion = "determine-cli-version"; const string PackCliToolOuter = "pack-cli-tool-outer"; const string PackCliToolRid = "pack-cli-tool-rid"; const string PushCliToNuGet = "push-cli-to-nuget"; Target( Clean, ["**/bin", "**/obj"], dir => { IEnumerable GetDirectories(string d) { return Glob.Directories(".", d); } void RemoveDirectory(string d) { if (Directory.Exists(d)) { Console.WriteLine(d); Directory.Delete(d, true); } } foreach (var d in GetDirectories(dir)) { RemoveDirectory(d); } } ); Target( Format, () => { Run("dotnet", "tool restore"); Run("dotnet", "csharpier format ."); } ); Target( CheckFormat, () => { Run("dotnet", "tool restore"); Run("dotnet", "csharpier check ."); } ); Target(Restore, [CheckFormat], () => Run("dotnet", "restore")); Target( Build, [Restore], () => { Run("dotnet", "build src/SharpCompress/SharpCompress.csproj -c Release --no-restore"); } ); Target( Test, [Build], ["net10.0", "net48"], framework => { IEnumerable GetFiles(string d) { return Glob.Files(".", d); } if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && framework == "net48") { return; } foreach (var file in GetFiles("**/*.Test.csproj")) { if (!ProjectSupportsFramework(file, framework)) { continue; } Run("dotnet", $"test {file} -c Release -f {framework} --no-restore --verbosity=normal"); } } ); Target( Publish, [Test], () => { Run("dotnet", "pack src/SharpCompress/SharpCompress.csproj -c Release -o artifacts/"); } ); Target( DetermineVersion, async () => { var (version, isPrerelease) = await GetVersion(); Console.WriteLine($"VERSION={version}"); Console.WriteLine( $"PRERELEASE={isPrerelease.ToString().ToLower(CultureInfo.InvariantCulture)}" ); // Write to environment file for GitHub Actions var githubOutput = Environment.GetEnvironmentVariable("GITHUB_OUTPUT"); if (!string.IsNullOrEmpty(githubOutput)) { File.AppendAllText(githubOutput, $"version={version}\n"); File.AppendAllText( githubOutput, $"prerelease={isPrerelease.ToString().ToLower(CultureInfo.InvariantCulture)}\n" ); } } ); Target( UpdateVersion, async () => { var version = Environment.GetEnvironmentVariable("VERSION"); if (string.IsNullOrEmpty(version)) { var (detectedVersion, _) = await GetVersion(); version = detectedVersion; } Console.WriteLine($"Updating project file with version: {version}"); var projectPath = "src/SharpCompress/SharpCompress.csproj"; var content = File.ReadAllText(projectPath); // Get base version (without prerelease suffix) var baseVersion = version.Split('-')[0]; // Update VersionPrefix content = Regex.Replace( content, @"[^<]*", $"{version}" ); // Update AssemblyVersion content = Regex.Replace( content, @"[^<]*", $"{baseVersion}" ); // Update FileVersion content = Regex.Replace( content, @"[^<]*", $"{baseVersion}" ); File.WriteAllText(projectPath, content); Console.WriteLine($"Updated VersionPrefix to: {version}"); Console.WriteLine($"Updated AssemblyVersion and FileVersion to: {baseVersion}"); } ); Target( PushToNuGet, () => { var apiKey = Environment.GetEnvironmentVariable("NUGET_API_KEY"); if (string.IsNullOrEmpty(apiKey)) { Console.WriteLine( "NUGET_API_KEY environment variable is not set. Skipping NuGet push." ); return; } var packages = Directory.GetFiles("artifacts", "*.nupkg"); if (packages.Length == 0) { Console.WriteLine("No packages found in artifacts directory."); return; } foreach (var package in packages) { Console.WriteLine($"Pushing {package} to NuGet.org"); try { // Note: API key is passed via command line argument which is standard practice for dotnet nuget push // The key is already in an environment variable and not displayed in normal output Run( "dotnet", $"nuget push \"{package}\" --api-key {apiKey} --source https://api.nuget.org/v3/index.json --skip-duplicate" ); } catch (Exception ex) { Console.WriteLine($"Failed to push {package}: {ex.Message}"); throw; } } } ); Target( DisplayBenchmarkResults, () => { var githubStepSummary = Environment.GetEnvironmentVariable("GITHUB_STEP_SUMMARY"); var resultsDir = "benchmark-results/results"; if (!Directory.Exists(resultsDir)) { Console.WriteLine("No benchmark results found."); return; } var markdownFiles = Directory .GetFiles(resultsDir, "*-report-github.md") .OrderBy(f => f) .ToList(); if (markdownFiles.Count == 0) { Console.WriteLine("No benchmark markdown reports found."); return; } var output = new List { "## Benchmark Results", "" }; foreach (var file in markdownFiles) { Console.WriteLine($"Processing {Path.GetFileName(file)}"); var content = File.ReadAllText(file); output.Add(content); output.Add(""); } // Write to GitHub Step Summary if available if (!string.IsNullOrEmpty(githubStepSummary)) { File.AppendAllLines(githubStepSummary, output); Console.WriteLine($"Benchmark results written to GitHub Step Summary"); } else { // Write to console if not in GitHub Actions foreach (var line in output) { Console.WriteLine(line); } } } ); Target( CompareBenchmarkResults, () => { var githubStepSummary = Environment.GetEnvironmentVariable("GITHUB_STEP_SUMMARY"); var baselinePath = "tests/SharpCompress.Performance/baseline-results.md"; var resultsDir = "benchmark-results/results"; var output = new List { "## Comparison with Baseline", "" }; if (!File.Exists(baselinePath)) { Console.WriteLine("Baseline file not found"); output.Add("⚠️ Baseline file not found. Run `generate-baseline` to create it."); WriteOutput(output, githubStepSummary); return; } if (!Directory.Exists(resultsDir)) { Console.WriteLine("No current benchmark results found."); output.Add("⚠️ No current benchmark results found. Showing baseline only."); output.Add(""); output.Add("### Baseline Results"); output.AddRange(File.ReadAllLines(baselinePath)); WriteOutput(output, githubStepSummary); return; } var markdownFiles = Directory .GetFiles(resultsDir, "*-report-github.md") .OrderBy(f => f) .ToList(); if (markdownFiles.Count == 0) { Console.WriteLine("No current benchmark markdown reports found."); output.Add("⚠️ No current benchmark results found. Showing baseline only."); output.Add(""); output.Add("### Baseline Results"); output.AddRange(File.ReadAllLines(baselinePath)); WriteOutput(output, githubStepSummary); return; } Console.WriteLine("Parsing baseline results..."); var baselineMetrics = ParseBenchmarkResults(File.ReadAllText(baselinePath)); Console.WriteLine("Parsing current results..."); var currentText = string.Join("\n", markdownFiles.Select(f => File.ReadAllText(f))); var currentMetrics = ParseBenchmarkResults(currentText); Console.WriteLine("Comparing results..."); output.Add("### Performance Comparison"); output.Add(""); output.Add( "| Benchmark | Baseline Mean | Current Mean | Change | Baseline Memory | Current Memory | Change |" ); output.Add( "|-----------|---------------|--------------|--------|-----------------|----------------|--------|" ); var hasRegressions = false; var hasImprovements = false; foreach (var method in currentMetrics.Keys.Union(baselineMetrics.Keys).OrderBy(k => k)) { var hasCurrent = currentMetrics.TryGetValue(method, out var current); var hasBaseline = baselineMetrics.TryGetValue(method, out var baseline); if (!hasCurrent) { output.Add( $"| {method} | {baseline!.Mean} | ❌ Missing | N/A | {baseline.Memory} | N/A | N/A |" ); continue; } if (!hasBaseline) { output.Add( $"| {method} | ❌ New | {current!.Mean} | N/A | N/A | {current.Memory} | N/A |" ); continue; } var timeChange = CalculateChange(baseline!.MeanValue, current!.MeanValue); var memChange = CalculateChange(baseline.MemoryValue, current.MemoryValue); var timeIcon = timeChange > 25 ? "🔴" : timeChange < -25 ? "🟢" : "⚪"; var memIcon = memChange > 25 ? "🔴" : memChange < -25 ? "🟢" : "⚪"; if (timeChange > 25 || memChange > 25) { hasRegressions = true; } if (timeChange < -25 || memChange < -25) { hasImprovements = true; } output.Add( $"| {method} | {baseline.Mean} | {current.Mean} | {timeIcon} {timeChange:+0.0;-0.0;0}% | {baseline.Memory} | {current.Memory} | {memIcon} {memChange:+0.0;-0.0;0}% |" ); } output.Add(""); output.Add("**Legend:**"); output.Add("- 🔴 Regression (>25% slower/more memory)"); output.Add("- 🟢 Improvement (>25% faster/less memory)"); output.Add("- ⚪ No significant change"); if (hasRegressions) { output.Add(""); output.Add( "⚠️ **Warning**: Performance regressions detected. Review the changes carefully." ); } else if (hasImprovements) { output.Add(""); output.Add("✅ Performance improvements detected!"); } else { output.Add(""); output.Add("✅ Performance is stable compared to baseline."); } WriteOutput(output, githubStepSummary); } ); Target( DetermineCliVersion, async () => { var version = await GetCliToolVersion(); Console.WriteLine($"CLI_VERSION={version}"); var githubOutput = Environment.GetEnvironmentVariable("GITHUB_OUTPUT"); if (!string.IsNullOrEmpty(githubOutput)) { File.AppendAllText(githubOutput, $"cli_version={version}\n"); } } ); Target( PackCliToolOuter, () => { var version = GetRequiredEnvironmentVariable("CLI_VERSION"); var configuration = Environment.GetEnvironmentVariable("CLI_CONFIGURATION") ?? "Release"; const string project = "src/SharpCompress.Cli/SharpCompress.Cli.csproj"; const string outputDirectory = "artifacts/cli"; Directory.CreateDirectory(outputDirectory); Run("dotnet", $"restore {project}"); Run( "dotnet", $"pack {project} -c {configuration} -o {outputDirectory} -p:PackageVersion={version} -p:PublishAot=true --no-restore" ); } ); Target( PackCliToolRid, () => { var version = GetRequiredEnvironmentVariable("CLI_VERSION"); var rid = GetRequiredEnvironmentVariable("CLI_RID"); var configuration = Environment.GetEnvironmentVariable("CLI_CONFIGURATION") ?? "Release"; const string project = "src/SharpCompress.Cli/SharpCompress.Cli.csproj"; const string outputDirectory = "artifacts/cli"; Directory.CreateDirectory(outputDirectory); Run("dotnet", $"restore {project} -r {rid}"); Run( "dotnet", $"pack {project} -c {configuration} -o {outputDirectory} -p:PackageVersion={version} -p:PublishAot=true -p:RuntimeIdentifier={rid} --no-restore" ); } ); Target( PushCliToNuGet, () => { var apiKey = Environment.GetEnvironmentVariable("NUGET_API_KEY"); if (string.IsNullOrEmpty(apiKey)) { Console.WriteLine( "NUGET_API_KEY environment variable is not set. Skipping CLI NuGet push." ); return; } var outputDirectory = Path.Combine("artifacts", "cli"); if (!Directory.Exists(outputDirectory)) { Console.WriteLine("CLI artifacts directory does not exist. Nothing to push."); return; } var packages = Directory .GetFiles(outputDirectory, "*.nupkg") .OrderBy(path => path) .ToList(); if (packages.Count == 0) { Console.WriteLine("No CLI packages found in artifacts/cli directory."); return; } foreach (var package in packages) { Console.WriteLine($"Pushing {package} to NuGet.org"); Run( "dotnet", $"nuget push \"{package}\" --api-key {apiKey} --source https://api.nuget.org/v3/index.json --skip-duplicate" ); } } ); Target( GenerateBaseline, () => { var perfProject = "tests/SharpCompress.Performance/SharpCompress.Performance.csproj"; var baselinePath = "tests/SharpCompress.Performance/baseline-results.md"; var artifactsDir = "baseline-artifacts"; Console.WriteLine("Building performance project..."); Run("dotnet", $"build {perfProject} --configuration Release"); Console.WriteLine("Running benchmarks to generate baseline..."); Run( "dotnet", $"run --project {perfProject} --configuration Release --no-build -- --filter \"*\" --exporters markdown --artifacts {artifactsDir}" ); var resultsDir = Path.Combine(artifactsDir, "results"); if (!Directory.Exists(resultsDir)) { Console.WriteLine("ERROR: No benchmark results generated."); return; } var markdownFiles = Directory .GetFiles(resultsDir, "*-report-github.md") .OrderBy(f => f) .ToList(); if (markdownFiles.Count == 0) { Console.WriteLine("ERROR: No markdown reports found."); return; } Console.WriteLine($"Combining {markdownFiles.Count} benchmark reports..."); var baselineContent = new List(); foreach (var file in markdownFiles) { var lines = File.ReadAllLines(file); baselineContent.AddRange(lines.Select(l => l.Trim()).Where(l => l.StartsWith('|'))); } File.WriteAllText(baselinePath, string.Join(Environment.NewLine, baselineContent)); Console.WriteLine($"Baseline written to {baselinePath}"); // Clean up artifacts directory if (Directory.Exists(artifactsDir)) { Directory.Delete(artifactsDir, true); Console.WriteLine("Cleaned up artifacts directory."); } } ); Target( CliNative, () => { var rid = GetRequiredEnvironmentVariable("CLI_RID"); var configuration = Environment.GetEnvironmentVariable("CLI_CONFIGURATION") ?? "Release"; var framework = Environment.GetEnvironmentVariable("CLI_FRAMEWORK") ?? "net10.0"; const string project = "src/SharpCompress.Cli/SharpCompress.Cli.csproj"; Run("dotnet", $"restore {project} -r {rid}"); Run( "dotnet", $"publish {project} -c {configuration} -f {framework} -r {rid} -p:PublishAot=true --no-restore" ); var binaryName = rid.StartsWith("win-", StringComparison.OrdinalIgnoreCase) ? "SharpCompress.Cli.exe" : "SharpCompress.Cli"; var binaryPath = Path.Combine( "src", "SharpCompress.Cli", "bin", configuration, framework, rid, "publish", binaryName ); Console.WriteLine($"Smoke testing native binary: {binaryPath}"); Run(binaryPath, "formats --format json"); } ); Target("default", [Publish], () => Console.WriteLine("Done!")); await RunTargetsAndExitAsync(args); static async Task<(string version, bool isPrerelease)> GetVersion() { // Check if current commit has a version tag var currentTag = (await GetGitOutput("tag", "--points-at HEAD")) .Split('\n', StringSplitOptions.RemoveEmptyEntries) .FirstOrDefault(tag => Regex.IsMatch(tag.Trim(), @"^\d+\.\d+\.\d+$")); if (!string.IsNullOrEmpty(currentTag)) { // Tagged release - use the tag as version var version = currentTag.Trim(); Console.WriteLine($"Building tagged release version: {version}"); return (version, false); } else { // Not tagged - create prerelease version var allTags = (await GetGitOutput("tag", "--list")) .Split('\n', StringSplitOptions.RemoveEmptyEntries) .Where(tag => Regex.IsMatch(tag.Trim(), @"^\d+\.\d+\.\d+$")) .Select(tag => tag.Trim()) .ToList(); var lastTag = allTags.OrderBy(tag => Version.Parse(tag)).LastOrDefault() ?? "0.0.0"; var lastVersion = Version.Parse(lastTag); // Determine version increment based on branch var currentBranch = await GetCurrentBranch(); Version nextVersion; if (currentBranch == "release") { // Release branch: increment patch version nextVersion = new Version(lastVersion.Major, lastVersion.Minor, lastVersion.Build + 1); Console.WriteLine($"Building prerelease for release branch (patch increment)"); } else { // Master or other branches: increment minor version nextVersion = new Version(lastVersion.Major, lastVersion.Minor + 1, 0); Console.WriteLine($"Building prerelease for {currentBranch} branch (minor increment)"); } // Use commit count since the last version tag if available; otherwise, fall back to total count var revListArgs = allTags.Any() ? $"--count {lastTag}..HEAD" : "--count HEAD"; var commitCount = (await GetGitOutput("rev-list", revListArgs)).Trim(); var version = $"{nextVersion}-beta.{commitCount}"; Console.WriteLine($"Building prerelease version: {version}"); return (version, true); } } static async Task GetCliToolVersion() { var githubRefName = Environment.GetEnvironmentVariable("GITHUB_REF_NAME"); if (TryParseCliVersionTag(githubRefName, out var githubVersion)) { Console.WriteLine($"Building CLI tagged release version: {githubVersion}"); return githubVersion; } var currentTag = (await GetGitOutput("tag", "--points-at HEAD")) .Split('\n', StringSplitOptions.RemoveEmptyEntries) .Select(tag => tag.Trim()) .FirstOrDefault(tag => TryParseCliVersionTag(tag, out _)); if (TryParseCliVersionTag(currentTag, out var tagVersion)) { Console.WriteLine($"Building CLI tagged release version: {tagVersion}"); return tagVersion; } throw new InvalidOperationException( "Could not determine CLI version. Expected tag format: SharpCompress.Cli.." ); } static bool TryParseCliVersionTag(string? tagName, out string version) { version = string.Empty; if (string.IsNullOrWhiteSpace(tagName)) { return false; } var match = Regex.Match( tagName.Trim(), @"^SharpCompress\.Cli\.(\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?)$" ); if (!match.Success) { return false; } version = match.Groups[1].Value; return true; } static async Task GetCurrentBranch() { // In GitHub Actions, GITHUB_REF_NAME contains the branch name var githubRefName = Environment.GetEnvironmentVariable("GITHUB_REF_NAME"); if (!string.IsNullOrEmpty(githubRefName)) { return githubRefName; } // Fallback to git command for local builds try { var (output, _) = await ReadAsync("git", "branch --show-current"); return output.Trim(); } catch (Exception ex) { Console.WriteLine($"Warning: Could not determine current branch: {ex.Message}"); return "unknown"; } } static async Task GetGitOutput(string command, string args) { try { // Use SimpleExec's Read to execute git commands in a cross-platform way var (output, _) = await ReadAsync("git", $"{command} {args}"); return output; } catch (Exception ex) { throw new InvalidOperationException( $"Git command failed: git {command} {args}\n{ex.Message}", ex ); } } static void WriteOutput(List output, string? githubStepSummary) { if (!string.IsNullOrEmpty(githubStepSummary)) { File.AppendAllLines(githubStepSummary, output); Console.WriteLine("Comparison written to GitHub Step Summary"); } else { foreach (var line in output) { Console.WriteLine(line); } } } static Dictionary ParseBenchmarkResults(string markdown) { var metrics = new Dictionary(); var lines = markdown.Split('\n'); for (int i = 0; i < lines.Length; i++) { var line = lines[i].Trim(); // Look for table rows with benchmark data if (line.StartsWith('|') && line.Contains("'", StringComparison.Ordinal) && i > 0) { var parts = line.Split('|', StringSplitOptions.TrimEntries); if (parts.Length >= 5) { var method = parts[1].Replace("'", "'", StringComparison.Ordinal); var meanStr = parts[2]; // Find Allocated column - it's usually the last column or labeled "Allocated" string memoryStr = "N/A"; for (int j = parts.Length - 2; j >= 2; j--) { if ( parts[j].Contains("KB", StringComparison.Ordinal) || parts[j].Contains("MB", StringComparison.Ordinal) || parts[j].Contains("GB", StringComparison.Ordinal) || parts[j].Contains('B', StringComparison.Ordinal) ) { memoryStr = parts[j]; break; } } if ( !method.Equals("Method", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(method) ) { var metric = new BenchmarkMetric { Method = method, Mean = meanStr, MeanValue = ParseTimeValue(meanStr), Memory = memoryStr, MemoryValue = ParseMemoryValue(memoryStr), }; metrics[method] = metric; } } } } return metrics; } static double ParseTimeValue(string timeStr) { if (string.IsNullOrWhiteSpace(timeStr) || timeStr == "N/A" || timeStr == "NA") { return 0; } // Remove thousands separators and parse timeStr = timeStr.Replace(",", "", StringComparison.Ordinal).Trim(); var match = Regex.Match(timeStr, @"([\d.]+)\s*(\w+)"); if (!match.Success) { return 0; } var value = double.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture); var unit = match.Groups[2].Value.ToLower(CultureInfo.InvariantCulture); // Convert to microseconds for comparison return unit switch { "s" => value * 1_000_000, "ms" => value * 1_000, "μs" or "us" => value, "ns" => value / 1_000, _ => value, }; } static double ParseMemoryValue(string memStr) { if (string.IsNullOrWhiteSpace(memStr) || memStr == "N/A" || memStr == "NA") { return 0; } memStr = memStr.Replace(",", "", StringComparison.Ordinal).Trim(); var match = Regex.Match(memStr, @"([\d.]+)\s*(\w+)"); if (!match.Success) { return 0; } var value = double.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture); var unit = match.Groups[2].Value.ToUpper(CultureInfo.InvariantCulture); // Convert to KB for comparison return unit switch { "GB" => value * 1_024 * 1_024, "MB" => value * 1_024, "KB" => value, "B" => value / 1_024, _ => value, }; } static double CalculateChange(double baseline, double current) { if (baseline == 0) { return 0; } return ((current - baseline) / baseline) * 100; } static string GetRequiredEnvironmentVariable(string variableName) { var value = Environment.GetEnvironmentVariable(variableName); if (string.IsNullOrWhiteSpace(value)) { throw new InvalidOperationException( $"Environment variable '{variableName}' must be set for this target." ); } return value; } static bool ProjectSupportsFramework(string projectFile, string framework) { var project = XDocument.Load(projectFile); var frameworkValues = project .Descendants() .Where(element => element.Name.LocalName.Equals("TargetFramework", StringComparison.Ordinal) || element.Name.LocalName.Equals("TargetFrameworks", StringComparison.Ordinal) ) .SelectMany(element => (element.Value ?? string.Empty).Split( ';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries ) ) .Distinct(StringComparer.OrdinalIgnoreCase) .ToList(); if (frameworkValues.Count == 0) { return true; } return frameworkValues.Contains(framework, StringComparer.OrdinalIgnoreCase); } record BenchmarkMetric { public string Method { get; init; } = ""; public string Mean { get; init; } = ""; public double MeanValue { get; init; } public string Memory { get; init; } = ""; public double MemoryValue { get; init; } }