Prepared release v26.11.1 (#1173)

This commit is contained in:
Thorsten Sommer authored and GitHub committed 2026-11-07 13:14:58 +01:00
1 parent 9d1768e2f4
commit 520758771b
11 files changed
+339 -66

No files matched your search

+170 -38
View File
@@ -1,25 +1,62 @@
using System.Formats.Tar;
using System.IO.Compression;
using Build.Tools;
using SharedTools;
namespace Build.Commands;
public static class Pdfium
{
private static readonly HttpClient CLIENT = new()
private const int MAX_DOWNLOAD_ATTEMPTS = 3;
private static readonly TimeSpan CONNECT_TIMEOUT = TimeSpan.FromSeconds(30);
private static readonly TimeSpan RESPONSE_TIMEOUT = TimeSpan.FromSeconds(60);
private static readonly TimeSpan TRANSFER_TIMEOUT = TimeSpan.FromMinutes(5);
private static readonly TimeSpan RETRY_DELAY = TimeSpan.FromSeconds(5);
//
// Without a handler of its own, HttpClient waits for a connection without any limit, so a single
// connection attempt which hangs uses up the whole timeout of the request. That happened twice while
// preparing v26.11.1, while curl fetched the same file within a second. The handler limits each
// connection attempt, the TLS handshake included, and each download attempt has its own time budget:
//
private static readonly HttpClient CLIENT = new(new SocketsHttpHandler { ConnectTimeout = CONNECT_TIMEOUT })
{
Timeout = TimeSpan.FromMinutes(5)
Timeout = Timeout.InfiniteTimeSpan
};
/// <summary>
/// Installs the Pdfium library of the given version into the resources of the Rust runtime.
/// </summary>
/// <remarks>
/// Every failure ends the build: without the library, AI Studio cannot read PDF files, and Tauri would fail
/// later with a far less clear message, because its resources include the folder of the library. The download
/// is skipped when the library of this version is already installed, which a version marker in the target
/// folder of the runtime records. The marker cannot sit next to the library, because Tauri ships everything
/// in that folder.
/// </remarks>
/// <param name="rid">The RID to install the library for.</param>
/// <param name="version">The Chromium revision of the Pdfium build, e.g., 7763.</param>
/// <param name="offline">True, when the build must not download anything and uses the library already present.</param>
public static async Task InstallAsync(RID rid, string version, bool offline)
{
Console.Write($"- Installing Pdfium {version} for {rid.UserFriendlyName} ...");
var cwd = Environment.GetRustRuntimeDirectory();
var pdfiumUrl = GetPdfiumDownloadUrl(rid, version);
var library = GetLibraryPath(rid);
var pdfiumUrl = GetPdfiumDownloadUrl(rid, version);
if (string.IsNullOrWhiteSpace(library.Path) || string.IsNullOrWhiteSpace(pdfiumUrl))
{
Console.WriteLine(" failed.");
Console.WriteLine($"- Error: There is no Pdfium library for {rid.UserFriendlyName}.");
throw new CommandExitedException(1);
}
var cwd = Environment.GetRustRuntimeDirectory();
var pdfiumLibTargetPath = Path.Join(cwd, "resources", "libraries", library.Filename);
var versionMarkerPath = Path.Join(cwd, "target", "pdfium.version");
var versionMarker = $"{version} {rid.MicrosoftRid}";
if (offline)
{
@@ -29,45 +66,90 @@ public static class Pdfium
return;
}
Console.WriteLine($" failed because offline mode is enabled and '{pdfiumLibTargetPath}' does not exist");
return;
Console.WriteLine(" failed.");
Console.WriteLine($"- Error: Offline mode is enabled, but '{pdfiumLibTargetPath}' does not exist. Build once without --offline to download it.");
throw new CommandExitedException(1);
}
if (string.IsNullOrWhiteSpace(library.Path))
if (File.Exists(pdfiumLibTargetPath) && await ReadVersionMarkerAsync(versionMarkerPath) == versionMarker)
{
Console.WriteLine($" failed to find the library path for {rid.UserFriendlyName}");
Console.WriteLine(" already installed, skipping download");
return;
}
var pdfiumLibTargetDirectory = Path.Join(cwd, "resources", "libraries");
var pdfiumLibTmpTargetPath = Path.Join(pdfiumLibTargetDirectory, $"{library.Filename}.{Guid.NewGuid():N}.tmp");
var pdfiumLibArchivePath = library.Path.Replace('\\', '/');
//
// Download the file:
//
Console.Write(" downloading ...");
using var response = await CLIENT.GetAsync(pdfiumUrl, HttpCompletionOption.ResponseHeadersRead);
if (!response.IsSuccessStatusCode)
for (var attempt = 1; attempt <= MAX_DOWNLOAD_ATTEMPTS; attempt++)
{
Console.WriteLine($" failed to download Pdfium {version} for {rid.UserFriendlyName} from {pdfiumUrl}");
return;
try
{
await DownloadAndDeployAsync(pdfiumUrl, library.Path.Replace('\\', '/'), pdfiumLibTargetPath, versionMarkerPath, versionMarker);
Console.WriteLine(" done.");
return;
}
catch (Exception exception) when (IsTransientFailure(exception))
{
Console.Write($" failed: {DescribeFailure(exception)}");
if (attempt < MAX_DOWNLOAD_ATTEMPTS)
{
Console.Write($" Retrying in {RETRY_DELAY.TotalSeconds:0} seconds ...");
await Task.Delay(RETRY_DELAY);
}
}
}
//
// Extract the library from the downloaded file:
//
Console.Write(" extracting ...");
Directory.CreateDirectory(pdfiumLibTargetDirectory);
Console.WriteLine();
Console.WriteLine($"- Error: Could not download Pdfium {version} for {rid.UserFriendlyName} from {pdfiumUrl} in {MAX_DOWNLOAD_ATTEMPTS} attempts.");
if (File.Exists(pdfiumLibTargetPath))
Console.WriteLine(" To build with the library which is already present, use --offline.");
var foundLibrary = false;
throw new CommandExitedException(1);
}
/// <summary>
/// Downloads the Pdfium archive once, extracts the library from it, and puts the library in place.
/// </summary>
/// <remarks>
/// A failure which the next attempt might not run into, such as a broken connection or a timeout, leaves this
/// method as an exception, which IsTransientFailure recognizes. A failure which every attempt would run into,
/// such as an unknown version, ends the build right here.
/// </remarks>
/// <param name="pdfiumUrl">The address of the Pdfium archive.</param>
/// <param name="pdfiumLibArchivePath">The path of the library inside the archive, with forward slashes.</param>
/// <param name="pdfiumLibTargetPath">Where to put the library.</param>
/// <param name="versionMarkerPath">Where to record which version the library has.</param>
/// <param name="versionMarker">The version and RID of the library.</param>
private static async Task DownloadAndDeployAsync(string pdfiumUrl, string pdfiumLibArchivePath, string pdfiumLibTargetPath, string versionMarkerPath, string versionMarker)
{
var pdfiumLibTmpTargetPath = $"{pdfiumLibTargetPath}.{Guid.NewGuid():N}.tmp";
var receivedResponse = false;
using var attemptTimeout = new CancellationTokenSource(RESPONSE_TIMEOUT);
try
{
await using var downloadStream = await response.Content.ReadAsStreamAsync();
//
// Download the file:
//
Console.Write(" downloading ...");
using var response = await CLIENT.GetAsync(pdfiumUrl, HttpCompletionOption.ResponseHeadersRead, attemptTimeout.Token);
receivedResponse = true;
if (!response.IsSuccessStatusCode)
{
Console.WriteLine(" failed.");
Console.WriteLine($"- Error: The download of {pdfiumUrl} failed with {(int)response.StatusCode} {response.ReasonPhrase}. Check the Pdfium version in metadata.txt.");
throw new CommandExitedException(1);
}
//
// Extract the library from the downloaded file:
//
attemptTimeout.CancelAfter(TRANSFER_TIMEOUT);
Console.Write(" extracting ...");
Directory.CreateDirectory(Path.GetDirectoryName(pdfiumLibTargetPath)!);
var foundLibrary = false;
await using var downloadStream = await response.Content.ReadAsStreamAsync(attemptTimeout.Token);
await using var uncompressedStream = new GZipStream(downloadStream, CompressionMode.Decompress);
await using var tarReader = new TarReader(uncompressedStream);
while (await tarReader.GetNextEntryAsync() is { } entry)
while (await tarReader.GetNextEntryAsync(cancellationToken: attemptTimeout.Token) is { } entry)
{
if (!string.Equals(entry.Name.Replace('\\', '/'), pdfiumLibArchivePath, StringComparison.Ordinal))
continue;
@@ -76,35 +158,85 @@ public static class Pdfium
break;
await using var fileStream = File.Create(pdfiumLibTmpTargetPath);
await entry.DataStream.CopyToAsync(fileStream);
await entry.DataStream.CopyToAsync(fileStream, attemptTimeout.Token);
foundLibrary = true;
break;
}
if (!foundLibrary)
{
Console.WriteLine($" failed to find the library file '{pdfiumLibArchivePath}' in the Pdfium archive");
return;
Console.WriteLine(" failed.");
Console.WriteLine($"- Error: The Pdfium archive {pdfiumUrl} does not contain the library file '{pdfiumLibArchivePath}'.");
throw new CommandExitedException(1);
}
//
// Deploy the library. The marker goes first, so that it never describes a library
// which was only half replaced:
//
Console.Write(" deploying ...");
File.Delete(versionMarkerPath);
File.Move(pdfiumLibTmpTargetPath, pdfiumLibTargetPath, true);
Directory.CreateDirectory(Path.GetDirectoryName(versionMarkerPath)!);
await File.WriteAllTextAsync(versionMarkerPath, versionMarker, Environment.UTF8_NO_BOM);
}
catch (OperationCanceledException) when (attemptTimeout.IsCancellationRequested)
{
throw new TimeoutException(receivedResponse
? $"The download did not finish within {TRANSFER_TIMEOUT.TotalMinutes:0} minutes."
: $"There was no answer within {RESPONSE_TIMEOUT.TotalSeconds:0} seconds.");
}
catch (OperationCanceledException exception) when (exception.GetBaseException() is TimeoutException)
{
// The handler reports its connect timeout without the time it waited:
throw new TimeoutException($"No connection could be established within {CONNECT_TIMEOUT.TotalSeconds:0} seconds.");
}
finally
{
if (File.Exists(pdfiumLibTmpTargetPath))
File.Delete(pdfiumLibTmpTargetPath);
}
Console.WriteLine(" done.");
}
/// <summary>
/// Whether another attempt may succeed where a download attempt failed with the given exception.
/// </summary>
/// <param name="exception">The exception the attempt failed with.</param>
/// <returns>True, when the download is worth another attempt.</returns>
private static bool IsTransientFailure(Exception exception) => exception is HttpRequestException or OperationCanceledException or TimeoutException or IOException or InvalidDataException;
/// <summary>
/// Describes why a download attempt failed, in one line.
/// </summary>
/// <remarks>
/// The innermost exception names the actual cause, such as the socket error behind an HttpRequestException
/// or the timeout behind a canceled connection attempt, while the outer one often names the host.
/// </remarks>
/// <param name="exception">The exception the attempt failed with.</param>
/// <returns>The reason of the failure.</returns>
private static string DescribeFailure(Exception exception)
{
var innermost = exception.GetBaseException();
if (innermost == exception || exception is OperationCanceledException)
return innermost.Message;
return $"{exception.Message} {innermost.Message}";
}
private static async Task<string> ReadVersionMarkerAsync(string versionMarkerPath)
{
if (!File.Exists(versionMarkerPath))
return string.Empty;
return (await File.ReadAllTextAsync(versionMarkerPath, Encoding.UTF8)).Trim();
}
private static Library GetLibraryPath(RID rid) => rid switch
{
RID.LINUX_ARM64 or RID.LINUX_X64 => new(Path.Join("lib", "libpdfium.so"), "libpdfium.so"),
RID.OSX_ARM64 or RID.OSX_X64 => new(Path.Join("lib", "libpdfium.dylib"), "libpdfium.dylib"),
RID.WIN_ARM64 or RID.WIN_X64 => new(Path.Join("bin", "pdfium.dll"), "pdfium.dll"),
_ => new(string.Empty, string.Empty),
};
@@ -115,13 +247,13 @@ public static class Pdfium
{
RID.LINUX_ARM64 => $"{baseUrl}linux-arm64.tgz",
RID.LINUX_X64 => $"{baseUrl}linux-x64.tgz",
RID.OSX_ARM64 => $"{baseUrl}mac-arm64.tgz",
RID.OSX_X64 => $"{baseUrl}mac-x64.tgz",
RID.WIN_ARM64 => $"{baseUrl}win-arm64.tgz",
RID.WIN_X64 => $"{baseUrl}win-x64.tgz",
_ => string.Empty,
};
}
+95 -17
View File
@@ -270,12 +270,35 @@ public sealed partial class UpdateMetadataCommands
throw new CommandExitedException(1);
}
// The previous release created the changelog with a placeholder in its first line, which
// reserved the build number of this release. We fill in the build time below, so check now,
// before anything is written, that the placeholder is still there:
var changelogPlaceholderHeader = GetFirstLine(await File.ReadAllTextAsync(changelogPath, Encoding.UTF8));
var expectedBuildNumber = await ReadBuildNumber() + 1;
if (!IsChangelogPlaceholderHeader(changelogPlaceholderHeader, appVersion.VersionText, expectedBuildNumber))
{
Console.WriteLine($"- Error: The first line of '{Path.GetFileName(changelogPath)}' must be the placeholder '# v{appVersion.VersionText}, build {expectedBuildNumber} (YYYY-MM-xx xx:xx UTC)', but it is '{changelogPlaceholderHeader}'.");
throw new CommandExitedException(1);
}
try
{
await EnsureMetainfoReleaseCanBeWritten(appVersion.VersionText);
}
catch (InvalidOperationException exception)
{
Console.WriteLine($"- Error: {exception.Message}");
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);
var buildTimeText = await this.UpdateBuildTime();
var buildTime = ParseMetadataBuildTime(buildTimeText);
await UpdateChangelogHeader(changelogPath, changelogPlaceholderHeader, appVersion.VersionText, buildNumber, buildTime);
await this.UpdateChangelog(buildNumber, appVersion.VersionText, buildTimeText);
await this.CreateNextChangelog(buildNumber, appVersion);
await WriteMetainfoRelease(appVersion.VersionText, ParseMetadataBuildTime(buildTime));
await WriteMetainfoRelease(appVersion.VersionText, buildTime);
await this.UpdateProjectCommitHash();
await this.UpdateReleaseDependenciesAndLicence();
Console.WriteLine();
@@ -647,13 +670,8 @@ public sealed partial class UpdateMetadataCommands
var nextChangelog = nextChangelogCandidates[0];
// The release entry itself is written by ApplyRebuildReleaseState, which adds it when it is
// missing and moves it to the top otherwise. Here, we only ensure that there is a file to write to:
var metainfoPath = GetMetainfoPath();
if (!File.Exists(metainfoPath))
throw new InvalidOperationException("The AppStream metainfo file does not exist.");
if (!ReleasesStartRegex().IsMatch(await File.ReadAllTextAsync(metainfoPath, Encoding.UTF8)))
throw new InvalidOperationException("The AppStream metainfo does not contain a <releases> element.");
// missing and moves it to the top otherwise. Here, we only ensure that it can be written:
await EnsureMetainfoReleaseCanBeWritten(appVersion);
var headCommitHash = (await this.ReadCommandOutput(Environment.GetAIStudioDirectory(), "git", "rev-parse HEAD")).Text.Trim();
if (!GitCommitHashRegex().IsMatch(headCommitHash))
@@ -731,13 +749,7 @@ public sealed partial class UpdateMetadataCommands
const string RELEASE_INDENT = " ";
var metainfoPath = GetMetainfoPath();
if (!File.Exists(metainfoPath))
throw new InvalidOperationException("The AppStream metainfo file does not exist.");
var metainfo = await File.ReadAllTextAsync(metainfoPath, Encoding.UTF8);
if (!ReleasesStartRegex().IsMatch(metainfo))
throw new InvalidOperationException("The AppStream metainfo does not contain a <releases> element.");
var metainfo = await ReadMetainfo();
var changelogEntries = await ReadChangelogEntries(appVersion);
// Drop any earlier entry of this version, so that the version stays unique and moves to the top.
@@ -771,6 +783,34 @@ public sealed partial class UpdateMetadataCommands
Console.WriteLine($"- Updated the AppStream metainfo for v{appVersion}, released on {releaseDate}, with {changelogEntries.Count} changelog entries.");
}
/// <summary>
/// Checks everything WriteMetainfoRelease needs for the given version, without writing anything.
/// </summary>
/// <remarks>
/// Preparing a release writes the AppStream entry last, after the metadata and the changelogs were changed. When
/// it failed only there, e.g., because the changelog has no entry yet, it would leave a half-prepared release
/// behind. Hence, the commands call this before they write anything.
/// </remarks>
/// <param name="appVersion">The version whose release entry will be written.</param>
private static async Task EnsureMetainfoReleaseCanBeWritten(string appVersion)
{
await ReadMetainfo();
await ReadChangelogEntries(appVersion);
}
private static async Task<string> ReadMetainfo()
{
var metainfoPath = GetMetainfoPath();
if (!File.Exists(metainfoPath))
throw new InvalidOperationException("The AppStream metainfo file does not exist.");
var metainfo = await File.ReadAllTextAsync(metainfoPath, Encoding.UTF8);
if (!ReleasesStartRegex().IsMatch(metainfo))
throw new InvalidOperationException("The AppStream metainfo does not contain a <releases> element.");
return metainfo;
}
private static async Task<IReadOnlyList<string>> ReadChangelogEntries(string appVersion)
{
var changelogPath = GetChangelogPath(appVersion);
@@ -853,6 +893,37 @@ public sealed partial class UpdateMetadataCommands
return $"# v{appVersion}, build {buildNumber} ({buildTime.ToUniversalTime():yyyy-MM-dd HH:mm} UTC)";
}
/// <summary>
/// Whether a changelog header is the placeholder which the previous release created for this version and build.
/// </summary>
/// <remarks>
/// The month of the placeholder is only a guess of when the release would happen, so any month matches.
/// </remarks>
/// <param name="changelogHeader">The first line of the changelog.</param>
/// <param name="appVersion">The version of the release.</param>
/// <param name="buildNumber">The build number of the release.</param>
/// <returns>True, when the header is the placeholder.</returns>
private static bool IsChangelogPlaceholderHeader(string changelogHeader, string appVersion, int buildNumber)
{
return Regex.IsMatch(changelogHeader, $"^# v{Regex.Escape(appVersion)}, build {buildNumber} \\(20[0-9]{{2}}-[0-9]{{2}}-xx xx:xx UTC\\)$");
}
/// <summary>
/// Replaces the placeholder in the first line of a changelog with the header of the release.
/// </summary>
/// <param name="changelogPath">The path of the changelog.</param>
/// <param name="placeholderHeader">The placeholder, as checked before anything was written.</param>
/// <param name="appVersion">The version of the release.</param>
/// <param name="buildNumber">The build number of the release.</param>
/// <param name="buildTime">The build time of the release.</param>
private static async Task UpdateChangelogHeader(string changelogPath, string placeholderHeader, string appVersion, int buildNumber, DateTime buildTime)
{
var changelogContent = await File.ReadAllTextAsync(changelogPath, Encoding.UTF8);
var updatedChangelog = ReplaceExactlyOnce(changelogContent, placeholderHeader, FormatChangelogHeader(appVersion, buildNumber, buildTime));
await File.WriteAllTextAsync(changelogPath, updatedChangelog, Environment.UTF8_NO_BOM);
Console.WriteLine($"- Updated the header of '{Path.GetFileName(changelogPath)}'.");
}
private static string FormatChangelogLogEntry(string appVersion, int buildNumber, DateTime buildTime, string changelogFilename)
{
return $"new ({buildNumber}, \"v{appVersion}, build {buildNumber} ({buildTime.ToUniversalTime():yyyy-MM-dd HH:mm} UTC)\", \"{changelogFilename}\"),";
@@ -1263,6 +1334,13 @@ public sealed partial class UpdateMetadataCommands
return new(sb.ToString(), process.ExitCode);
}
private static async Task<int> ReadBuildNumber()
{
const int BUILD_NUMBER_INDEX = 2;
var lines = await File.ReadAllLinesAsync(Environment.GetMetadataPath(), Encoding.UTF8);
return int.Parse(lines[BUILD_NUMBER_INDEX]);
}
private async Task<int> IncreaseBuildNumber()
{
const int BUILD_NUMBER_INDEX = 2;