From b1e5199f6208cb8d5d77babf0447e83909992b91 Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Thu, 15 Oct 2026 19:53:06 +0200 Subject: [PATCH] Made the build script report failed builds through its exit code (#1067) --- app/Build/Commands/UpdateMetadataCommands.cs | 139 ++++++++++++------- app/Build/Tools/CommandOutput.cs | 12 ++ 2 files changed, 98 insertions(+), 53 deletions(-) create mode 100644 app/Build/Tools/CommandOutput.cs diff --git a/app/Build/Commands/UpdateMetadataCommands.cs b/app/Build/Commands/UpdateMetadataCommands.cs index 9630e5155..a153e6b7b 100644 --- a/app/Build/Commands/UpdateMetadataCommands.cs +++ b/app/Build/Commands/UpdateMetadataCommands.cs @@ -2,6 +2,8 @@ using System.Diagnostics; using System.Globalization; using System.Text.RegularExpressions; +using Build.Tools; + using SharedTools; namespace Build.Commands; @@ -20,26 +22,27 @@ public sealed partial class UpdateMetadataCommands [Option("offline", Description = "Skip downloads and use locally available build dependencies")] bool offline = false) { if(!Environment.IsWorkingDirectoryValid()) - return; + throw new CommandExitedException(1); // Validate parameters: either action or version must be specified, but not both: if (action == PrepareAction.NONE && string.IsNullOrWhiteSpace(version)) { Console.WriteLine("- Error: You must specify either --action (-a) or --version (-v)."); - return; + throw new CommandExitedException(1); } if (action != PrepareAction.NONE && !string.IsNullOrWhiteSpace(version)) { Console.WriteLine("- Error: You cannot specify both --action and --version. Please use only one."); - return; + throw new CommandExitedException(1); } // If version is specified, use SET action: if (!string.IsNullOrWhiteSpace(version)) action = PrepareAction.SET; - // Prepare the metadata for the next release: + // Prepare the metadata for the next release. When this fails, it ends the + // whole command, so that nothing is built from half-prepared metadata: await this.PerformPrepare(action, true, version); await this.BuildPreparedRelease(offline); @@ -50,7 +53,7 @@ public sealed partial class UpdateMetadataCommands [Option("offline", Description = "Skip downloads and use locally available build dependencies")] bool offline = false) { if(!Environment.IsWorkingDirectoryValid()) - return; + throw new CommandExitedException(1); Console.WriteLine("=============================="); Console.WriteLine("- Prepare a new build of the current release ..."); @@ -63,7 +66,7 @@ public sealed partial class UpdateMetadataCommands catch (InvalidOperationException exception) { Console.WriteLine($"- Error: {exception.Message}"); - return; + throw new CommandExitedException(1); } await this.ApplyRebuildReleaseState(releaseState, DateTime.UtcNow); @@ -76,7 +79,8 @@ public sealed partial class UpdateMetadataCommands private async Task BuildPreparedRelease(bool offline) { // Build once to allow the Rust compiler to read the changed metadata - // and to update all .NET artifacts: + // and to update all .NET artifacts. When this build fails, it ends the + // whole command; nothing below runs on top of its broken artifacts: await this.Build(offline); // Now, we update the web assets (which may were updated by the first build): @@ -187,18 +191,20 @@ public sealed partial class UpdateMetadataCommands Console.WriteLine("- Prepare the metadata for the next release ..."); - var appVersion = await this.UpdateAppVersion(action, version); + var appVersion = await this.DetermineNextAppVersion(action, version); if (!string.IsNullOrWhiteSpace(appVersion.VersionText)) { // The changelog is the source for the AppStream description. Check it before we write - // any further metadata, so that a missing changelog cannot leave a half-prepared release: + // any metadata, the version included, so that a missing changelog cannot leave a + // half-prepared release: var changelogPath = GetChangelogPath(appVersion.VersionText); if (!File.Exists(changelogPath)) { Console.WriteLine($"- Error: The changelog file '{Path.GetFileName(changelogPath)}' does not exist."); - return; + throw new CommandExitedException(1); } + await this.UpdateAppVersion(appVersion); var buildNumber = await this.IncreaseBuildNumber(); var buildTime = await this.UpdateBuildTime(); await this.UpdateChangelog(buildNumber, appVersion.VersionText, buildTime); @@ -227,7 +233,7 @@ public sealed partial class UpdateMetadataCommands [Option("skip-verify", Description = "Skip the quality gate which otherwise runs before anything is built")] bool skipVerify = false) { if(!Environment.IsWorkingDirectoryValid()) - return; + throw new CommandExitedException(1); // // The gate runs before anything is built, and the build stops when it does not pass. That @@ -255,7 +261,7 @@ public sealed partial class UpdateMetadataCommands Console.Write($"- Start .NET build for {rid.ToUserFriendlyName()} ..."); await this.ReadCommandOutput(pathApp, "dotnet", $"clean --configuration release --runtime {rid.AsMicrosoftRid()}"); var dotnetBuildOutput = await this.ReadCommandOutput(pathApp, "dotnet", $"publish --configuration release --runtime {rid.AsMicrosoftRid()} --disable-build-servers --force"); - var dotnetBuildOutputLines = dotnetBuildOutput.Split([global::System.Environment.NewLine], StringSplitOptions.RemoveEmptyEntries); + var dotnetBuildOutputLines = dotnetBuildOutput.Text.Split([global::System.Environment.NewLine], StringSplitOptions.RemoveEmptyEntries); var foundIssue = false; foreach (var buildOutputLine in dotnetBuildOutputLines) { @@ -275,11 +281,24 @@ public sealed partial class UpdateMetadataCommands if(foundIssue) Console.WriteLine(); - else + else if(dotnetBuildOutput.ExitCode is 0) { Console.WriteLine(" completed successfully."); } - + else + { + // Unlike the Rust build, this one ran silently. When none of its lines looks like a + // diagnostic, its whole output is the only thing left to go on: + Console.WriteLine(" failed:"); + Console.WriteLine(dotnetBuildOutput.Text); + } + + if(dotnetBuildOutput.ExitCode is not 0) + { + Console.WriteLine($"- Error: The .NET build failed with exit code {dotnetBuildOutput.ExitCode}."); + throw new CommandExitedException(1); + } + // // Prepare the .NET artifact to be used by Tauri as sidecar: // @@ -301,7 +320,7 @@ public sealed partial class UpdateMetadataCommands if (string.IsNullOrWhiteSpace(tauriSidecarArtifactName)) { Console.WriteLine($"- Error: Unsupported rid '{rid.AsMicrosoftRid()}'."); - return; + throw new CommandExitedException(1); } var dotnetArtifactPath = Path.Combine(pathApp, "bin", "dist"); @@ -322,7 +341,7 @@ public sealed partial class UpdateMetadataCommands else { Console.WriteLine($"- Error: Published .NET artifact not found: '{dotnetPublishedPath}'."); - return; + throw new CommandExitedException(1); } Console.Write($"- Move the .NET artifact to the Tauri sidecar destination ..."); @@ -333,8 +352,11 @@ public sealed partial class UpdateMetadataCommands } catch (Exception e) { + // Building on would not fail: Tauri would quietly take whichever sidecar an earlier build + // left in its place, and the result would contain an outdated .NET app: Console.WriteLine(" failed."); Console.WriteLine($" - Error: {e.Message}"); + throw new CommandExitedException(1); } // @@ -344,7 +366,7 @@ public sealed partial class UpdateMetadataCommands var pathRuntime = Environment.GetRustRuntimeDirectory(); var rustBuildOutput = await this.ReadCommandOutput(pathRuntime, "cargo", "tauri build --no-bundle", true); - var rustBuildOutputLines = rustBuildOutput.Split([global::System.Environment.NewLine], StringSplitOptions.RemoveEmptyEntries); + var rustBuildOutputLines = rustBuildOutput.Text.Split([global::System.Environment.NewLine], StringSplitOptions.RemoveEmptyEntries); var foundRustIssue = false; foreach (var buildOutputLine in rustBuildOutputLines) { @@ -362,11 +384,15 @@ public sealed partial class UpdateMetadataCommands } } - if(foundRustIssue) - Console.WriteLine(); - else + Console.WriteLine(); + if(rustBuildOutput.ExitCode is not 0) + { + Console.WriteLine($"- Error: The Rust build failed with exit code {rustBuildOutput.ExitCode}."); + throw new CommandExitedException(1); + } + + if(!foundRustIssue) { - Console.WriteLine(); Console.WriteLine("- Compilation completed successfully."); Console.WriteLine(); } @@ -555,7 +581,7 @@ public sealed partial class UpdateMetadataCommands if (!ReleasesStartRegex().IsMatch(await File.ReadAllTextAsync(metainfoPath, Encoding.UTF8))) throw new InvalidOperationException("The AppStream metainfo does not contain a element."); - var headCommitHash = (await this.ReadCommandOutput(Environment.GetAIStudioDirectory(), "git", "rev-parse HEAD")).Trim(); + var headCommitHash = (await this.ReadCommandOutput(Environment.GetAIStudioDirectory(), "git", "rev-parse HEAD")).Text.Trim(); if (!GitCommitHashRegex().IsMatch(headCommitHash)) throw new InvalidOperationException("The current Git commit hash could not be determined."); @@ -850,7 +876,7 @@ public sealed partial class UpdateMetadataCommands var pathMetadata = Environment.GetMetadataPath(); var lines = await File.ReadAllLinesAsync(pathMetadata, Encoding.UTF8); var currentCommitHash = lines[COMMIT_HASH_INDEX].Trim(); - var headCommitHash = await this.ReadCommandOutput(Environment.GetAIStudioDirectory(), "git", "rev-parse HEAD"); + var headCommitHash = (await this.ReadCommandOutput(Environment.GetAIStudioDirectory(), "git", "rev-parse HEAD")).Text; var first10Chars = headCommitHash[..11]; var updatedCommitHash = $"{first10Chars}, release"; @@ -860,7 +886,7 @@ public sealed partial class UpdateMetadataCommands await WriteMetadataLinesAsync(pathMetadata, lines); } - private async Task UpdateAppVersion(PrepareAction action, string? version = null) + private async Task DetermineNextAppVersion(PrepareAction action, string? version = null) { const int APP_VERSION_INDEX = 0; @@ -882,7 +908,7 @@ public sealed partial class UpdateMetadataCommands if (!versionMatch.Success) { Console.WriteLine($"- Error: Invalid version format '{version}'. Expected format: major.minor.patch (e.g., 26.1.2)"); - return new(string.Empty, 0, 0, 0); + throw new CommandExitedException(1); } newMajor = int.Parse(versionMatch.Groups["major"].Value); @@ -916,13 +942,19 @@ public sealed partial class UpdateMetadataCommands } } - var updatedAppVersion = $"{newMajor}.{newMinor}.{newPatch}"; - Console.WriteLine($"- Updating app version from '{currentAppVersionLine}' to '{updatedAppVersion}'."); + return new($"{newMajor}.{newMinor}.{newPatch}", newMajor, newMinor, newPatch); + } - lines[APP_VERSION_INDEX] = updatedAppVersion; + private async Task UpdateAppVersion(AppVersion appVersion) + { + const int APP_VERSION_INDEX = 0; + + var pathMetadata = Environment.GetMetadataPath(); + var lines = await File.ReadAllLinesAsync(pathMetadata, Encoding.UTF8); + Console.WriteLine($"- Updating app version from '{lines[APP_VERSION_INDEX].Trim()}' to '{appVersion.VersionText}'."); + + lines[APP_VERSION_INDEX] = appVersion.VersionText; await WriteMetadataLinesAsync(pathMetadata, lines); - - return new(updatedAppVersion, newMajor, newMinor, newPatch); } private async Task UpdateLicenceYear(string licenceFilePath) @@ -1116,7 +1148,7 @@ public sealed partial class UpdateMetadataCommands return matches; } - private async Task ReadCommandOutput(string workingDirectory, string program, string command, bool showLiveOutput = false) + private async Task ReadCommandOutput(string workingDirectory, string program, string command, bool showLiveOutput = false) { var processInfo = new ProcessStartInfo { @@ -1129,35 +1161,36 @@ public sealed partial class UpdateMetadataCommands CreateNoWindow = true, }; + // + // Both streams deliver their lines on threads of their own, so the lines are collected under + // a lock. The handlers are in place before reading starts; otherwise, the first lines may get + // lost, and with them the diagnostic which says why a build failed: + // var sb = new StringBuilder(); + var sbLock = new Lock(); + void CollectLine(string? line) + { + if(string.IsNullOrWhiteSpace(line)) + return; + + lock (sbLock) + { + if(showLiveOutput) + Console.WriteLine(line); + sb.AppendLine(line); + } + } + using var process = new Process(); process.StartInfo = processInfo; + process.OutputDataReceived += (_, args) => CollectLine(args.Data); + process.ErrorDataReceived += (_, args) => CollectLine(args.Data); process.Start(); process.BeginOutputReadLine(); process.BeginErrorReadLine(); - - process.OutputDataReceived += (_, args) => - { - if(!string.IsNullOrWhiteSpace(args.Data)) - { - if(showLiveOutput) - Console.WriteLine(args.Data); - sb.AppendLine(args.Data); - } - }; - - process.ErrorDataReceived += (_, args) => - { - if(!string.IsNullOrWhiteSpace(args.Data)) - { - if(showLiveOutput) - Console.WriteLine(args.Data); - sb.AppendLine(args.Data); - } - }; - + await process.WaitForExitAsync(); - return sb.ToString(); + return new(sb.ToString(), process.ExitCode); } private async Task IncreaseBuildNumber() diff --git a/app/Build/Tools/CommandOutput.cs b/app/Build/Tools/CommandOutput.cs new file mode 100644 index 000000000..06165459a --- /dev/null +++ b/app/Build/Tools/CommandOutput.cs @@ -0,0 +1,12 @@ +namespace Build.Tools; + +/// +/// What an external tool wrote, and how it ended. +/// +/// +/// The text alone cannot tell whether a tool failed: a build may stop on a diagnostic which no filter +/// recognizes, or without writing any at all. Only the exit code answers that question reliably. +/// +/// Everything the tool wrote to its standard output and its standard error, line by line. +/// The exit code of the tool, where anything but zero is a failure. +public readonly record struct CommandOutput(string Text, int ExitCode); \ No newline at end of file