From 63754261e95a689830a302473813833ebb346232 Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Thu, 13 Aug 2026 10:27:26 +0200 Subject: [PATCH] Generate the AppStream metainfo release entry during release preparation --- app/Build/Commands/UpdateMetadataCommands.cs | 201 ++++++++++++++++--- 1 file changed, 170 insertions(+), 31 deletions(-) diff --git a/app/Build/Commands/UpdateMetadataCommands.cs b/app/Build/Commands/UpdateMetadataCommands.cs index 51c5a7e8..1447a64a 100644 --- a/app/Build/Commands/UpdateMetadataCommands.cs +++ b/app/Build/Commands/UpdateMetadataCommands.cs @@ -91,6 +91,40 @@ public sealed partial class UpdateMetadataCommands await this.Build(offline); } + [Command("update-metainfo", Description = "Update the AppStream metainfo entry of one release from its changelog")] + public async Task UpdateMetainfo( + [Option("version", ['v'], Description = "The release version, e.g., 26.1.2. Defaults to the version from the metadata")] string? version = null, + [Option("date", ['d'], Description = "The release date as yyyy-MM-dd. Defaults to the build time from the metadata")] string? date = null) + { + const int APP_VERSION_INDEX = 0; + const int BUILD_TIME_INDEX = 1; + + if(!Environment.IsWorkingDirectoryValid()) + return; + + Console.WriteLine("=============================="); + + try + { + var metadataLines = SplitLines(await File.ReadAllTextAsync(Environment.GetMetadataPath(), Encoding.UTF8)); + var appVersion = string.IsNullOrWhiteSpace(version) ? metadataLines[APP_VERSION_INDEX].Trim() : version.Trim(); + if (!ExactAppVersionRegex().IsMatch(appVersion)) + throw new InvalidOperationException($"The version '{appVersion}' is not a valid app version."); + + DateTime releaseTime; + if (string.IsNullOrWhiteSpace(date)) + releaseTime = ParseMetadataBuildTime(metadataLines[BUILD_TIME_INDEX]); + else if (!DateTime.TryParseExact(date.Trim(), "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out releaseTime)) + throw new InvalidOperationException($"The release date '{date}' is not a valid date in the yyyy-MM-dd format."); + + await WriteMetainfoRelease(appVersion, releaseTime); + } + catch (InvalidOperationException exception) + { + Console.WriteLine($"- Error: {exception.Message}"); + } + } + [Command("update-versions", Description = "The command will update the package versions in the metadata file")] public async Task UpdateVersions() { @@ -154,10 +188,20 @@ public sealed partial class UpdateMetadataCommands var appVersion = await this.UpdateAppVersion(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: + var changelogPath = GetChangelogPath(appVersion.VersionText); + if (!File.Exists(changelogPath)) + { + Console.WriteLine($"- Error: The changelog file '{Path.GetFileName(changelogPath)}' does not exist."); + return; + } + var buildNumber = await this.IncreaseBuildNumber(); var buildTime = await this.UpdateBuildTime(); await this.UpdateChangelog(buildNumber, appVersion.VersionText, buildTime); await this.CreateNextChangelog(buildNumber, appVersion); + await WriteMetainfoRelease(appVersion.VersionText, ParseMetadataBuildTime(buildTime)); await this.UpdateProjectCommitHash(); await this.UpdateReleaseDependenciesAndLicence(); Console.WriteLine(); @@ -413,9 +457,7 @@ public sealed partial class UpdateMetadataCommands if (!ExactAppVersionRegex().IsMatch(appVersion)) throw new InvalidOperationException($"The metadata version '{appVersion}' is not a valid app version."); - if (!DateTime.TryParseExact(metadataLines[BUILD_TIME_INDEX].Trim(), "yyyy-MM-dd HH:mm:ss 'UTC'", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var buildTime)) - throw new InvalidOperationException($"The metadata build time '{metadataLines[BUILD_TIME_INDEX]}' is not a valid UTC build time."); - + var buildTime = ParseMetadataBuildTime(metadataLines[BUILD_TIME_INDEX]); if (!int.TryParse(metadataLines[BUILD_NUMBER_INDEX].Trim(), out var buildNumber)) throw new InvalidOperationException($"The metadata build number '{metadataLines[BUILD_NUMBER_INDEX]}' is not a number."); @@ -455,19 +497,15 @@ public sealed partial class UpdateMetadataCommands throw new InvalidOperationException($"Expected exactly one future changelog reserving build {nextChangelogBuildNumber}, but found {nextChangelogCandidates.Count}."); var nextChangelog = nextChangelogCandidates[0]; - var metainfoPath = Path.Combine(Environment.GetRustRuntimeDirectory(), "packaging", "linux", "org.mindworkai.AIStudio.metainfo.xml"); + + // 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."); - var metainfoContent = await File.ReadAllTextAsync(metainfoPath, Encoding.UTF8); - var releaseTags = ReleaseTagRegex().Matches(metainfoContent).Cast().ToList(); - var matchingReleaseTags = releaseTags.Where(match => ReleaseTagHasVersion(match.Value, appVersion)).ToList(); - if (matchingReleaseTags.Count != 1 || releaseTags.Count == 0 || matchingReleaseTags[0].Index != releaseTags[0].Index) - throw new InvalidOperationException($"The AppStream metainfo must contain v{appVersion} exactly once as its first release."); - - var metainfoReleaseTag = matchingReleaseTags[0].Value; - if (!StableReleaseTypeRegex().IsMatch(metainfoReleaseTag) || !ReleaseDateRegex().IsMatch(metainfoReleaseTag)) - throw new InvalidOperationException($"The AppStream entry for v{appVersion} must be stable and contain a release date."); + 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(); if (!GitCommitHashRegex().IsMatch(headCommitHash)) @@ -489,9 +527,6 @@ public sealed partial class UpdateMetadataCommands nextChangelog.Content, nextChangelog.Header, nextChangelog.Version, - metainfoPath, - metainfoContent, - metainfoReleaseTag, headCommitHash[..11]); } @@ -530,11 +565,119 @@ public sealed partial class UpdateMetadataCommands await File.WriteAllTextAsync(releaseState.NextChangelogPath, updatedNextChangelog, Environment.UTF8_NO_BOM); Console.WriteLine($"- Reserved build {buildNumber + 1} for '{Path.GetFileName(releaseState.NextChangelogPath)}'."); - var releaseDate = buildTime.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); - var updatedMetainfoReleaseTag = ReleaseDateRegex().Replace(releaseState.MetainfoReleaseTag, $"date=\"{releaseDate}\"", 1); - var updatedMetainfo = ReplaceExactlyOnce(releaseState.MetainfoContent, releaseState.MetainfoReleaseTag, updatedMetainfoReleaseTag); - await File.WriteAllTextAsync(releaseState.MetainfoPath, updatedMetainfo, Environment.UTF8_NO_BOM); - Console.WriteLine($"- Updated the AppStream release date to '{releaseDate}'."); + await WriteMetainfoRelease(releaseState.AppVersion, buildTime); + } + + private static string GetMetainfoPath() => Path.Combine(Environment.GetRustRuntimeDirectory(), "packaging", "linux", "org.mindworkai.AIStudio.metainfo.xml"); + + private static string GetChangelogPath(string appVersion) => Path.Combine(Environment.GetAIStudioDirectory(), "wwwroot", "changelog", $"v{appVersion}.md"); + + /// + /// Writes the AppStream release entry for the given version, using the changelog of that version as its description. + /// + /// + /// The entry always becomes the first release, and any earlier entry of the same version is replaced. This is what + /// the Flatpak pipeline validates through 'update-metainfo.py --check' before it syncs a release. The release date + /// is derived from the build time, because the pipeline reads it from the second line of the metadata file. + /// + private static async Task WriteMetainfoRelease(string appVersion, DateTime releaseTime) + { + 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 element."); + + var changelogEntries = await ReadChangelogEntries(appVersion); + + // Drop any earlier entry of this version, so that the version stays unique and moves to the top. + // We remove from the back, so that the index of the remaining matches stays valid: + foreach (var previousRelease in ReleaseBlockRegex().Matches(metainfo).Cast().Where(match => ReleaseTagHasVersion(match.Value, appVersion)).Reverse()) + metainfo = metainfo.Remove(previousRelease.Index, previousRelease.Length); + + var lineEnding = metainfo.Contains("\r\n", StringComparison.Ordinal) ? "\r\n" : "\n"; + var releaseDate = releaseTime.ToUniversalTime().ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); + var releaseBlock = new StringBuilder(); + releaseBlock.Append($"{RELEASE_INDENT}{lineEnding}"); + releaseBlock.Append($"{RELEASE_INDENT} {lineEnding}"); + releaseBlock.Append($"{RELEASE_INDENT}
    {lineEnding}"); + + foreach (var changelogEntry in changelogEntries) + releaseBlock.Append($"{RELEASE_INDENT}
  • {changelogEntry}
  • {lineEnding}"); + + releaseBlock.Append($"{RELEASE_INDENT}
{lineEnding}"); + releaseBlock.Append($"{RELEASE_INDENT}
{lineEnding}"); + releaseBlock.Append($"{RELEASE_INDENT}
{lineEnding}"); + + var releasesStart = ReleasesStartRegex().Match(metainfo); + var insertionPoint = releasesStart.Index + releasesStart.Length; + if (metainfo.AsSpan(insertionPoint).StartsWith(lineEnding)) + insertionPoint += lineEnding.Length; + else + releaseBlock.Insert(0, lineEnding); + + metainfo = metainfo.Insert(insertionPoint, releaseBlock.ToString()); + await File.WriteAllTextAsync(metainfoPath, metainfo, Environment.UTF8_NO_BOM); + Console.WriteLine($"- Updated the AppStream metainfo for v{appVersion}, released on {releaseDate}, with {changelogEntries.Count} changelog entries."); + } + + private static async Task> ReadChangelogEntries(string appVersion) + { + var changelogPath = GetChangelogPath(appVersion); + if (!File.Exists(changelogPath)) + throw new InvalidOperationException($"The changelog file '{Path.GetFileName(changelogPath)}' does not exist."); + + // The first line is the changelog header, every other non-empty line must be a changelog entry: + var changelogLines = SplitLines(await File.ReadAllTextAsync(changelogPath, Encoding.UTF8)); + var changelogEntries = new List(); + foreach (var changelogLine in changelogLines.Skip(1)) + { + var changelogEntry = changelogLine.Trim(); + if (changelogEntry.Length is 0) + continue; + + if (!changelogEntry.StartsWith("- ", StringComparison.Ordinal)) + throw new InvalidOperationException($"The changelog '{Path.GetFileName(changelogPath)}' contains a line which is no changelog entry: '{changelogEntry}'."); + + changelogEntries.Add(ConvertChangelogEntryToAppStream(changelogEntry[2..].Trim())); + } + + if (changelogEntries.Count is 0) + throw new InvalidOperationException($"The changelog '{Path.GetFileName(changelogPath)}' does not contain any entry."); + + return changelogEntries; + } + + private static string ConvertChangelogEntryToAppStream(string changelogEntry) + { + var escapedEntry = changelogEntry + .Replace("&", "&", StringComparison.Ordinal) + .Replace("<", "<", StringComparison.Ordinal) + .Replace(">", ">", StringComparison.Ordinal); + + // Markdown code spans become AppStream code elements. Every second segment is inside a code span, + // which requires an even number of markers and therefore an odd number of segments: + var codeSpans = escapedEntry.Split('`'); + if (codeSpans.Length % 2 is 0) + throw new InvalidOperationException($"The changelog entry contains an unbalanced code marker: '{changelogEntry}'."); + + var convertedEntry = new StringBuilder(); + for (var index = 0; index < codeSpans.Length; index++) + convertedEntry.Append(index % 2 is 0 ? codeSpans[index] : $"{codeSpans[index]}"); + + return convertedEntry.ToString(); + } + + private static DateTime ParseMetadataBuildTime(string buildTime) + { + if (!DateTime.TryParseExact(buildTime.Trim(), "yyyy-MM-dd HH:mm:ss 'UTC'", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var parsedBuildTime)) + throw new InvalidOperationException($"The metadata build time '{buildTime}' is not a valid UTC build time."); + + return parsedBuildTime; } private static string FormatChangelogHeader(string appVersion, int buildNumber, DateTime buildTime) @@ -983,9 +1126,6 @@ public sealed partial class UpdateMetadataCommands string NextChangelogContent, string NextChangelogHeader, string NextChangelogVersion, - string MetainfoPath, - string MetainfoContent, - string MetainfoReleaseTag, string HeadCommitHash); [GeneratedRegex("""(?ms).?(NET\s+SDK|SDK\s+\.NET)\s*:\s+Version:\s+(?[0-9.]+).+Commit:\s+(?[a-zA-Z0-9]+).+Host:\s+Version:\s+(?[0-9.]+).+Commit:\s+(?[a-zA-Z0-9]+)""")] @@ -1015,14 +1155,13 @@ public sealed partial class UpdateMetadataCommands [GeneratedRegex("""^[0-9]+\.[0-9]+\.[0-9]+$""")] private static partial Regex ExactAppVersionRegex(); - [GeneratedRegex("""]*>""")] - private static partial Regex ReleaseTagRegex(); + [GeneratedRegex("""]*>""")] + private static partial Regex ReleasesStartRegex(); - [GeneratedRegex("\\btype=\"stable\"")] - private static partial Regex StableReleaseTypeRegex(); - - [GeneratedRegex("\\bdate=\"[^\"]*\"")] - private static partial Regex ReleaseDateRegex(); + // Matches one entire release element, including its indentation and its trailing line break. The + // self-closing form comes first, so that it is never mistaken for the start of a longer element: + [GeneratedRegex("""(?ms)^[ \t]*]*/>[ \t]*\r?\n?|^[ \t]*]*>.*?[ \t]*\r?\n?""")] + private static partial Regex ReleaseBlockRegex(); [GeneratedRegex("^[0-9a-fA-F]{40,64}$")] private static partial Regex GitCommitHashRegex();