Made the build script report failed builds through its exit code (#1067)

This commit is contained in:
Thorsten Sommer authored and GitHub committed 2026-10-15 19:53:06 +02:00
1 parent da0c4e6bd9
commit b1e5199f62
2 files changed
+95 -50

No files matched your search

+83 -50
View File
@@ -2,6 +2,8 @@ using System.Diagnostics;
using System.Globalization; using System.Globalization;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using Build.Tools;
using SharedTools; using SharedTools;
namespace Build.Commands; 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) [Option("offline", Description = "Skip downloads and use locally available build dependencies")] bool offline = false)
{ {
if(!Environment.IsWorkingDirectoryValid()) if(!Environment.IsWorkingDirectoryValid())
return; throw new CommandExitedException(1);
// Validate parameters: either action or version must be specified, but not both: // Validate parameters: either action or version must be specified, but not both:
if (action == PrepareAction.NONE && string.IsNullOrWhiteSpace(version)) if (action == PrepareAction.NONE && string.IsNullOrWhiteSpace(version))
{ {
Console.WriteLine("- Error: You must specify either --action (-a) or --version (-v)."); Console.WriteLine("- Error: You must specify either --action (-a) or --version (-v).");
return; throw new CommandExitedException(1);
} }
if (action != PrepareAction.NONE && !string.IsNullOrWhiteSpace(version)) if (action != PrepareAction.NONE && !string.IsNullOrWhiteSpace(version))
{ {
Console.WriteLine("- Error: You cannot specify both --action and --version. Please use only one."); 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 version is specified, use SET action:
if (!string.IsNullOrWhiteSpace(version)) if (!string.IsNullOrWhiteSpace(version))
action = PrepareAction.SET; 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.PerformPrepare(action, true, version);
await this.BuildPreparedRelease(offline); 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) [Option("offline", Description = "Skip downloads and use locally available build dependencies")] bool offline = false)
{ {
if(!Environment.IsWorkingDirectoryValid()) if(!Environment.IsWorkingDirectoryValid())
return; throw new CommandExitedException(1);
Console.WriteLine("=============================="); Console.WriteLine("==============================");
Console.WriteLine("- Prepare a new build of the current release ..."); Console.WriteLine("- Prepare a new build of the current release ...");
@@ -63,7 +66,7 @@ public sealed partial class UpdateMetadataCommands
catch (InvalidOperationException exception) catch (InvalidOperationException exception)
{ {
Console.WriteLine($"- Error: {exception.Message}"); Console.WriteLine($"- Error: {exception.Message}");
return; throw new CommandExitedException(1);
} }
await this.ApplyRebuildReleaseState(releaseState, DateTime.UtcNow); await this.ApplyRebuildReleaseState(releaseState, DateTime.UtcNow);
@@ -76,7 +79,8 @@ public sealed partial class UpdateMetadataCommands
private async Task BuildPreparedRelease(bool offline) private async Task BuildPreparedRelease(bool offline)
{ {
// Build once to allow the Rust compiler to read the changed metadata // 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); await this.Build(offline);
// Now, we update the web assets (which may were updated by the first build): // 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 ..."); 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)) if (!string.IsNullOrWhiteSpace(appVersion.VersionText))
{ {
// The changelog is the source for the AppStream description. Check it before we write // 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); var changelogPath = GetChangelogPath(appVersion.VersionText);
if (!File.Exists(changelogPath)) if (!File.Exists(changelogPath))
{ {
Console.WriteLine($"- Error: The changelog file '{Path.GetFileName(changelogPath)}' does not exist."); 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 buildNumber = await this.IncreaseBuildNumber();
var buildTime = await this.UpdateBuildTime(); var buildTime = await this.UpdateBuildTime();
await this.UpdateChangelog(buildNumber, appVersion.VersionText, buildTime); 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) [Option("skip-verify", Description = "Skip the quality gate which otherwise runs before anything is built")] bool skipVerify = false)
{ {
if(!Environment.IsWorkingDirectoryValid()) 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 // 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()} ..."); Console.Write($"- Start .NET build for {rid.ToUserFriendlyName()} ...");
await this.ReadCommandOutput(pathApp, "dotnet", $"clean --configuration release --runtime {rid.AsMicrosoftRid()}"); 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 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; var foundIssue = false;
foreach (var buildOutputLine in dotnetBuildOutputLines) foreach (var buildOutputLine in dotnetBuildOutputLines)
{ {
@@ -275,10 +281,23 @@ public sealed partial class UpdateMetadataCommands
if(foundIssue) if(foundIssue)
Console.WriteLine(); Console.WriteLine();
else else if(dotnetBuildOutput.ExitCode is 0)
{ {
Console.WriteLine(" completed successfully."); 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: // Prepare the .NET artifact to be used by Tauri as sidecar:
@@ -301,7 +320,7 @@ public sealed partial class UpdateMetadataCommands
if (string.IsNullOrWhiteSpace(tauriSidecarArtifactName)) if (string.IsNullOrWhiteSpace(tauriSidecarArtifactName))
{ {
Console.WriteLine($"- Error: Unsupported rid '{rid.AsMicrosoftRid()}'."); Console.WriteLine($"- Error: Unsupported rid '{rid.AsMicrosoftRid()}'.");
return; throw new CommandExitedException(1);
} }
var dotnetArtifactPath = Path.Combine(pathApp, "bin", "dist"); var dotnetArtifactPath = Path.Combine(pathApp, "bin", "dist");
@@ -322,7 +341,7 @@ public sealed partial class UpdateMetadataCommands
else else
{ {
Console.WriteLine($"- Error: Published .NET artifact not found: '{dotnetPublishedPath}'."); 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 ..."); Console.Write($"- Move the .NET artifact to the Tauri sidecar destination ...");
@@ -333,8 +352,11 @@ public sealed partial class UpdateMetadataCommands
} }
catch (Exception e) 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(" failed.");
Console.WriteLine($" - Error: {e.Message}"); Console.WriteLine($" - Error: {e.Message}");
throw new CommandExitedException(1);
} }
// //
@@ -344,7 +366,7 @@ public sealed partial class UpdateMetadataCommands
var pathRuntime = Environment.GetRustRuntimeDirectory(); var pathRuntime = Environment.GetRustRuntimeDirectory();
var rustBuildOutput = await this.ReadCommandOutput(pathRuntime, "cargo", "tauri build --no-bundle", true); 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; var foundRustIssue = false;
foreach (var buildOutputLine in rustBuildOutputLines) foreach (var buildOutputLine in rustBuildOutputLines)
{ {
@@ -362,11 +384,15 @@ public sealed partial class UpdateMetadataCommands
} }
} }
if(foundRustIssue)
Console.WriteLine(); Console.WriteLine();
else 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("- Compilation completed successfully.");
Console.WriteLine(); Console.WriteLine();
} }
@@ -555,7 +581,7 @@ public sealed partial class UpdateMetadataCommands
if (!ReleasesStartRegex().IsMatch(await File.ReadAllTextAsync(metainfoPath, Encoding.UTF8))) if (!ReleasesStartRegex().IsMatch(await File.ReadAllTextAsync(metainfoPath, Encoding.UTF8)))
throw new InvalidOperationException("The AppStream metainfo does not contain a <releases> element."); throw new InvalidOperationException("The AppStream metainfo does not contain a <releases> 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)) if (!GitCommitHashRegex().IsMatch(headCommitHash))
throw new InvalidOperationException("The current Git commit hash could not be determined."); 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 pathMetadata = Environment.GetMetadataPath();
var lines = await File.ReadAllLinesAsync(pathMetadata, Encoding.UTF8); var lines = await File.ReadAllLinesAsync(pathMetadata, Encoding.UTF8);
var currentCommitHash = lines[COMMIT_HASH_INDEX].Trim(); 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 first10Chars = headCommitHash[..11];
var updatedCommitHash = $"{first10Chars}, release"; var updatedCommitHash = $"{first10Chars}, release";
@@ -860,7 +886,7 @@ public sealed partial class UpdateMetadataCommands
await WriteMetadataLinesAsync(pathMetadata, lines); await WriteMetadataLinesAsync(pathMetadata, lines);
} }
private async Task<AppVersion> UpdateAppVersion(PrepareAction action, string? version = null) private async Task<AppVersion> DetermineNextAppVersion(PrepareAction action, string? version = null)
{ {
const int APP_VERSION_INDEX = 0; const int APP_VERSION_INDEX = 0;
@@ -882,7 +908,7 @@ public sealed partial class UpdateMetadataCommands
if (!versionMatch.Success) if (!versionMatch.Success)
{ {
Console.WriteLine($"- Error: Invalid version format '{version}'. Expected format: major.minor.patch (e.g., 26.1.2)"); 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); newMajor = int.Parse(versionMatch.Groups["major"].Value);
@@ -916,13 +942,19 @@ public sealed partial class UpdateMetadataCommands
} }
} }
var updatedAppVersion = $"{newMajor}.{newMinor}.{newPatch}"; return new($"{newMajor}.{newMinor}.{newPatch}", newMajor, newMinor, newPatch);
Console.WriteLine($"- Updating app version from '{currentAppVersionLine}' to '{updatedAppVersion}'."); }
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); await WriteMetadataLinesAsync(pathMetadata, lines);
return new(updatedAppVersion, newMajor, newMinor, newPatch);
} }
private async Task UpdateLicenceYear(string licenceFilePath) private async Task UpdateLicenceYear(string licenceFilePath)
@@ -1116,7 +1148,7 @@ public sealed partial class UpdateMetadataCommands
return matches; return matches;
} }
private async Task<string> ReadCommandOutput(string workingDirectory, string program, string command, bool showLiveOutput = false) private async Task<CommandOutput> ReadCommandOutput(string workingDirectory, string program, string command, bool showLiveOutput = false)
{ {
var processInfo = new ProcessStartInfo var processInfo = new ProcessStartInfo
{ {
@@ -1129,35 +1161,36 @@ public sealed partial class UpdateMetadataCommands
CreateNoWindow = true, 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 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(); using var process = new Process();
process.StartInfo = processInfo; process.StartInfo = processInfo;
process.OutputDataReceived += (_, args) => CollectLine(args.Data);
process.ErrorDataReceived += (_, args) => CollectLine(args.Data);
process.Start(); process.Start();
process.BeginOutputReadLine(); process.BeginOutputReadLine();
process.BeginErrorReadLine(); 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(); await process.WaitForExitAsync();
return sb.ToString(); return new(sb.ToString(), process.ExitCode);
} }
private async Task<int> IncreaseBuildNumber() private async Task<int> IncreaseBuildNumber()
+12
View File
@@ -0,0 +1,12 @@
namespace Build.Tools;
/// <summary>
/// What an external tool wrote, and how it ended.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
/// <param name="Text">Everything the tool wrote to its standard output and its standard error, line by line.</param>
/// <param name="ExitCode">The exit code of the tool, where anything but zero is a failure.</param>
public readonly record struct CommandOutput(string Text, int ExitCode);