diff --git a/src/TALXIS.CLI.Features.Environment/Solution/SolutionBuildOutput.cs b/src/TALXIS.CLI.Features.Environment/Solution/SolutionBuildOutput.cs
new file mode 100644
index 00000000..c5b00175
--- /dev/null
+++ b/src/TALXIS.CLI.Features.Environment/Solution/SolutionBuildOutput.cs
@@ -0,0 +1,53 @@
+using System.Text.RegularExpressions;
+
+namespace TALXIS.CLI.Features.Environment.Solution;
+
+internal enum BuildOutputSeverity
+{
+ Info,
+ Warning,
+ Error,
+}
+
+///
+/// Helpers for interpreting dotnet build output and locating the solution ZIP it produced.
+///
+internal static partial class SolutionBuildOutput
+{
+ [GeneratedRegex(@"\berror(\s+[A-Za-z]+\d+)?\s*:", RegexOptions.IgnoreCase)]
+ private static partial Regex ErrorPattern();
+
+ // Older Build SDKs pack an incomplete zip and exit 0 when root components have no source
+ // files; the only trace is one of these packager lines, printed without any Error: prefix.
+ [GeneratedRegex(@"^\s*(Following root components are not defined in customizations|Following objects, required by the solution, are not present)", RegexOptions.IgnoreCase)]
+ private static partial Regex MissingRootComponentsPattern();
+
+ [GeneratedRegex(@"\bwarning(\s+[A-Za-z]+\d+)?\s*:", RegexOptions.IgnoreCase)]
+ private static partial Regex WarningPattern();
+
+ internal static BuildOutputSeverity Classify(string line)
+ {
+ if (ErrorPattern().IsMatch(line)) return BuildOutputSeverity.Error;
+ if (MissingRootComponentsPattern().IsMatch(line)) return BuildOutputSeverity.Error;
+ if (WarningPattern().IsMatch(line)) return BuildOutputSeverity.Warning;
+
+ return BuildOutputSeverity.Info;
+ }
+
+ ///
+ /// Finds solution ZIPs under the build output directory (any target framework subfolder).
+ /// Fresh ZIPs are those written at or after , newest first.
+ ///
+ internal static (string[] Fresh, string[] All) FindSolutionZips(string binConfigDir, DateTime buildStartUtc)
+ {
+ if (!Directory.Exists(binConfigDir)) return (Array.Empty(), Array.Empty());
+
+ var all = Directory.GetFiles(binConfigDir, "*.zip", SearchOption.AllDirectories);
+ var fresh = all
+ .Where(f => File.GetLastWriteTimeUtc(f) >= buildStartUtc)
+ .OrderByDescending(File.GetLastWriteTimeUtc)
+ .ToArray();
+
+ return (fresh, all);
+ }
+}
diff --git a/src/TALXIS.CLI.Features.Environment/Solution/SolutionImportCliCommand.cs b/src/TALXIS.CLI.Features.Environment/Solution/SolutionImportCliCommand.cs
index 6f28374b..2716571a 100644
--- a/src/TALXIS.CLI.Features.Environment/Solution/SolutionImportCliCommand.cs
+++ b/src/TALXIS.CLI.Features.Environment/Solution/SolutionImportCliCommand.cs
@@ -199,7 +199,7 @@ protected override async Task ExecuteAsync()
}
///
- /// Runs dotnet build on a Build SDK project and locates the output ZIP.
+ /// Runs dotnet build on a Build SDK project and locates the ZIP produced by that build.
/// Returns the ZIP path on success, or null on failure.
///
private async Task BuildAndLocateZipAsync(string csProjPath)
@@ -207,6 +207,9 @@ protected override async Task ExecuteAsync()
var config = Managed ? "Release" : "Debug";
Logger.LogInformation("Building '{Project}' with configuration '{Config}'...", Path.GetFileName(csProjPath), config);
+ var buildStartUtc = DateTime.UtcNow;
+ var errorLineCount = 0;
+
var psi = new ProcessStartInfo
{
FileName = "dotnet",
@@ -219,7 +222,23 @@ protected override async Task ExecuteAsync()
};
using var process = new Process { StartInfo = psi };
- process.OutputDataReceived += (_, e) => { if (e.Data is not null) Logger.LogInformation("{Line}", e.Data); };
+ process.OutputDataReceived += (_, e) =>
+ {
+ if (e.Data is null) return;
+ switch (SolutionBuildOutput.Classify(e.Data))
+ {
+ case BuildOutputSeverity.Error:
+ Interlocked.Increment(ref errorLineCount);
+ Logger.LogError("{Line}", e.Data);
+ break;
+ case BuildOutputSeverity.Warning:
+ Logger.LogWarning("{Line}", e.Data);
+ break;
+ default:
+ Logger.LogInformation("{Line}", e.Data);
+ break;
+ }
+ };
process.ErrorDataReceived += (_, e) => { if (e.Data is not null) Logger.LogWarning("{Line}", e.Data); };
process.Start();
process.BeginOutputReadLine();
@@ -232,28 +251,31 @@ protected override async Task ExecuteAsync()
return null;
}
- // Build SDK convention: output ZIP is in bin/{config}/net462/*.zip
- var outputDir = Path.Combine(Path.GetDirectoryName(csProjPath)!, "bin", config, "net462");
- if (!Directory.Exists(outputDir))
+ // Older Build SDK versions report packager failures but still exit 0 (tools-devkit-build#47).
+ if (errorLineCount > 0)
{
- Logger.LogError("Build output directory not found: {OutputDir}.", outputDir);
+ Logger.LogError("Build succeeded but its output contains {Count} error line(s) (see above). Refusing to import.", errorLineCount);
return null;
}
- var zipFiles = Directory.GetFiles(outputDir, "*.zip");
- if (zipFiles.Length == 0)
+ var outputDir = Path.Combine(Path.GetDirectoryName(csProjPath)!, "bin", config);
+ var (freshZips, allZips) = SolutionBuildOutput.FindSolutionZips(outputDir, buildStartUtc);
+
+ if (allZips.Length == 0)
{
Logger.LogError("No .zip file found in build output directory: {OutputDir}.", outputDir);
return null;
}
- // Pick the most recently written ZIP to avoid using stale build artifacts
- var zipPath = zipFiles
- .OrderByDescending(f => new FileInfo(f).LastWriteTimeUtc)
- .First();
+ if (freshZips.Length == 0)
+ {
+ Logger.LogError("The build did not produce a solution ZIP; only stale artifacts from a previous build exist in '{OutputDir}'. Refusing to import a stale ZIP.", outputDir);
+ return null;
+ }
- if (zipFiles.Length > 1)
- Logger.LogWarning("Multiple .zip files found in '{OutputDir}'. Using newest: {ZipPath}", outputDir, Path.GetFileName(zipPath));
+ var zipPath = freshZips[0];
+ if (freshZips.Length > 1)
+ Logger.LogWarning("Multiple .zip files produced in '{OutputDir}'. Using newest: {ZipPath}", outputDir, Path.GetFileName(zipPath));
Logger.LogInformation("Using build output: {ZipPath}", zipPath);
return zipPath;
diff --git a/tests/TALXIS.CLI.Tests/Environment/Solution/SolutionBuildOutputTests.cs b/tests/TALXIS.CLI.Tests/Environment/Solution/SolutionBuildOutputTests.cs
new file mode 100644
index 00000000..f33d1cd1
--- /dev/null
+++ b/tests/TALXIS.CLI.Tests/Environment/Solution/SolutionBuildOutputTests.cs
@@ -0,0 +1,88 @@
+using TALXIS.CLI.Features.Environment.Solution;
+using Xunit;
+
+namespace TALXIS.CLI.Tests.Environment.Solution;
+
+public class SolutionBuildOutputTests
+{
+ [Theory]
+ [InlineData(" Error: RootComponent validation failed.", nameof(BuildOutputSeverity.Error))]
+ [InlineData(@"C:\proj\proj.csproj(4,5): error MSB4018: task failed", nameof(BuildOutputSeverity.Error))]
+ [InlineData("MSBUILD : error MSB1009: Project file does not exist.", nameof(BuildOutputSeverity.Error))]
+ [InlineData("Following root components are not defined in customizations:", nameof(BuildOutputSeverity.Error))]
+ [InlineData(" Following objects, required by the solution, are not present. ", nameof(BuildOutputSeverity.Error))]
+ [InlineData("proj.csproj : warning NU1903: Package has a known vulnerability", nameof(BuildOutputSeverity.Warning))]
+ [InlineData(" Warning: LocalBranchBuildVersionNumber is null", nameof(BuildOutputSeverity.Warning))]
+ [InlineData(" 0 Warning(s)", nameof(BuildOutputSeverity.Info))]
+ [InlineData(" 0 Error(s)", nameof(BuildOutputSeverity.Info))]
+ [InlineData("Build succeeded.", nameof(BuildOutputSeverity.Info))]
+ [InlineData(" Solution: bin\\Debug\\net462\\Sln.zip packed successfully", nameof(BuildOutputSeverity.Info))]
+ public void Classify_ReturnsExpectedSeverity(string line, string expected)
+ {
+ Assert.Equal(Enum.Parse(expected), SolutionBuildOutput.Classify(line));
+ }
+
+ [Fact]
+ public void FindZips_ReturnsEmpty_WhenDirectoryMissing()
+ {
+ var (fresh, all) = SolutionBuildOutput.FindSolutionZips(Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")), DateTime.UtcNow);
+ Assert.Empty(fresh);
+ Assert.Empty(all);
+ }
+
+ [Fact]
+ public void FindZips_FindsZipInAnyTfmSubfolder()
+ {
+ using var dir = new TempDir();
+ var zip = dir.CreateFile(Path.Combine("net472", "Sln.zip"));
+
+ var (fresh, all) = SolutionBuildOutput.FindSolutionZips(dir.Path, DateTime.UtcNow.AddMinutes(-1));
+
+ Assert.Equal([zip], fresh);
+ Assert.Equal([zip], all);
+ }
+
+ [Fact]
+ public void FindZips_ExcludesStaleZipsFromFresh()
+ {
+ using var dir = new TempDir();
+ var stale = dir.CreateFile(Path.Combine("net462", "Old.zip"));
+ File.SetLastWriteTimeUtc(stale, DateTime.UtcNow.AddHours(-2));
+
+ var (fresh, all) = SolutionBuildOutput.FindSolutionZips(dir.Path, DateTime.UtcNow.AddMinutes(-1));
+
+ Assert.Empty(fresh);
+ Assert.Equal([stale], all);
+ }
+
+ [Fact]
+ public void FindZips_OrdersFreshNewestFirst()
+ {
+ using var dir = new TempDir();
+ var older = dir.CreateFile(Path.Combine("net462", "Older.zip"));
+ var newer = dir.CreateFile(Path.Combine("net472", "Newer.zip"));
+ File.SetLastWriteTimeUtc(older, DateTime.UtcNow.AddSeconds(-30));
+ File.SetLastWriteTimeUtc(newer, DateTime.UtcNow);
+
+ var (fresh, _) = SolutionBuildOutput.FindSolutionZips(dir.Path, DateTime.UtcNow.AddMinutes(-1));
+
+ Assert.Equal([newer, older], fresh);
+ }
+
+ private sealed class TempDir : IDisposable
+ {
+ public string Path { get; } = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "txc-tests-" + Guid.NewGuid().ToString("N"));
+
+ public TempDir() => Directory.CreateDirectory(Path);
+
+ public string CreateFile(string relativePath)
+ {
+ var fullPath = System.IO.Path.Combine(Path, relativePath);
+ Directory.CreateDirectory(System.IO.Path.GetDirectoryName(fullPath)!);
+ File.WriteAllBytes(fullPath, [0x50, 0x4B]);
+ return fullPath;
+ }
+
+ public void Dispose() => Directory.Delete(Path, recursive: true);
+ }
+}