From 9ebacd4e84885bca7001657853e51335accb670f Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Fri, 16 Oct 2026 11:29:34 +0200 Subject: [PATCH 1/2] Give each version of the unified release notes preview its own page The generated docset has a page per product and version, named after the version, so a version can be shared by its URL. The product page lists the versions, the pages group entries by area, the navigation names the product and the version instead of "Release notes", and every page warns that the version is unreleased. Co-Authored-By: Claude Sonnet 5.5 --- .../UnifiedReleaseNotesBuildService.cs | 27 ++++--- .../UnifiedReleaseNotesDocset.cs | 76 ++++++++++++++++--- .../UnifiedReleaseNotesTests.cs | 62 ++++++++++++--- 3 files changed, 134 insertions(+), 31 deletions(-) diff --git a/src/services/Elastic.Documentation.Isolated/UnifiedReleaseNotesBuildService.cs b/src/services/Elastic.Documentation.Isolated/UnifiedReleaseNotesBuildService.cs index 601213288e..d8ed43b638 100644 --- a/src/services/Elastic.Documentation.Isolated/UnifiedReleaseNotesBuildService.cs +++ b/src/services/Elastic.Documentation.Isolated/UnifiedReleaseNotesBuildService.cs @@ -11,6 +11,7 @@ using Elastic.Documentation.Diagnostics; using Elastic.Documentation.FileSystems; using Elastic.Documentation.Services; +using Elastic.Documentation.Versions; using Microsoft.Extensions.Logging; namespace Elastic.Documentation.Isolated; @@ -53,7 +54,10 @@ IEnvironmentVariables environmentVariables { private readonly ILogger _logger = logFactory.CreateLogger(); - private sealed record ProductBundles(Product Product, IReadOnlyList<(string FileName, string Content)> Files); + private sealed record ProductBundles(ProductVersions Versions, IReadOnlyList<(string FileName, string Content)> Files) + { + public Product Product => Versions.Product; + } public async Task Build(IDiagnosticsCollector collector, UnifiedReleaseNotesBuildOptions options, Cancel ctx = default) { @@ -78,7 +82,7 @@ public async Task Build(IDiagnosticsCollector collector, UnifiedReleaseNot products = [.. bundles.Select(b => b.Product)]; var docsRoot = Path.Join(Paths.WorkingDirectoryRoot.FullName, "unified-release-notes", "docs"); - var mockFs = CreateVirtualDocset(docsRoot, products, bundles); + var mockFs = CreateVirtualDocset(docsRoot, bundles); PrepareOutput(realFs, options.Output.FullName); var docFs = DocumentationFileSystem.Resolve( @@ -155,7 +159,9 @@ UnifiedReleaseNotesBuildOptions options .Order(StringComparer.Ordinal) .Select(file => (FileName: realFs.Path.GetFileName(file), Content: realFs.File.ReadAllText(file))) .ToArray(); - result.Add(new ProductBundles(product, files)); + result.Add( + new ProductBundles(new ProductVersions(product, NewestFirst(loaded.Select(b => NormalizeVersion(b.Version)))), files) + ); } return result; @@ -169,22 +175,21 @@ private static IReadOnlyList MissingVersions(IEnumerable bundleV private static string NormalizeVersion(string version) => version.TrimStart('v'); - private static MockFileSystem CreateVirtualDocset( - string docsRoot, - IReadOnlyList products, - IReadOnlyList bundles - ) + private static IReadOnlyList NewestFirst(IEnumerable versions) => + [.. versions.Distinct(StringComparer.OrdinalIgnoreCase).OrderByDescending(VersionOrDate.Parse)]; + + private static MockFileSystem CreateVirtualDocset(string docsRoot, IReadOnlyList bundles) { var mockFs = new MockFileSystem(new MockFileSystemOptions { CurrentDirectory = docsRoot, CreateDefaultTempDir = false }); - foreach (var (relativePath, content) in UnifiedReleaseNotesDocset.Generate(products)) + foreach (var (relativePath, content) in UnifiedReleaseNotesDocset.Generate([.. bundles.Select(b => b.Versions)])) mockFs.AddFile(mockFs.Path.Join(docsRoot, relativePath), new MockFileData(content)); - foreach (var (product, files) in bundles) + foreach (var (productVersions, files) in bundles) { foreach (var (fileName, content) in files) { - var path = mockFs.Path.Join(docsRoot, UnifiedReleaseNotesDocset.BundlesPath(product.Id), fileName); + var path = mockFs.Path.Join(docsRoot, UnifiedReleaseNotesDocset.BundlesPath(productVersions.Product.Id), fileName); mockFs.AddFile(path, new MockFileData(content)); } } diff --git a/src/services/Elastic.Documentation.Isolated/UnifiedReleaseNotesDocset.cs b/src/services/Elastic.Documentation.Isolated/UnifiedReleaseNotesDocset.cs index 554eef67d6..9b74e060a2 100644 --- a/src/services/Elastic.Documentation.Isolated/UnifiedReleaseNotesDocset.cs +++ b/src/services/Elastic.Documentation.Isolated/UnifiedReleaseNotesDocset.cs @@ -7,18 +7,25 @@ namespace Elastic.Documentation.Isolated; +/// A product and the versions it has bundles for, newest first. +public sealed record ProductVersions(Product Product, IReadOnlyList Versions); + /// /// Synthesizes the markdown and navigation files of the unified release notes docset in memory. -/// Every product with release-notes: dra gets the same four pages, so onboarding a product -/// is a one-line change in products.yml. +/// Every product with release-notes: dra gets the same pages, so onboarding a product is a +/// one-line change in products.yml. A version has a page of its own, so its URL can be shared. /// public static class UnifiedReleaseNotesDocset { public const string BundlesFolder = "changelog/bundles"; + private const string UnreleasedNotice = + ":::{warning}\nThis is a preview of an unreleased version. The release notes can change until the version ships.\n:::"; + + private const string VersionOptions = ":highlights:\n:subsections:"; + private static readonly (string FileName, string NavigationTitle, string Heading, string Options)[] Pages = [ - ("index.md", "Release notes", "release notes", ":highlights:"), ("known-issues.md", "Known issues", "known issues", ":type: known-issue"), ("breaking-changes.md", "Breaking changes", "breaking changes", ":type: breaking-change\n:dropdowns:"), ("deprecations.md", "Deprecations", "deprecations", ":type: deprecation") @@ -32,7 +39,7 @@ public static IReadOnlyList SelectProducts(IEnumerable product public static string BundlesPath(string productId) => $"{BundlesFolder}/{productId}"; /// Generates every file of the docset, keyed by its path relative to the docset root. - public static IReadOnlyDictionary Generate(IReadOnlyList products) + public static IReadOnlyDictionary Generate(IReadOnlyList products) { var files = new Dictionary(StringComparer.Ordinal) { @@ -40,10 +47,13 @@ public static IReadOnlyDictionary Generate(IReadOnlyList Generate(IReadOnlyList products) + private static string GenerateToc(IReadOnlyList products) { var lines = new List { "toc:", " - folder: release-notes", " children:", " - file: index.md" }; - lines.AddRange(products.Select(p => $" - toc: {p.Id}")); + lines.AddRange(products.Select(p => $" - toc: {p.Product.Id}")); return string.Join('\n', lines) + "\n"; } - private static string GenerateProductToc() => string.Join('\n', Pages.Select(p => $" - file: {p.FileName}").Prepend("toc:")) + "\n"; + private static string GenerateProductToc(IReadOnlyList versions) + { + IEnumerable files = ["index.md", .. versions.Select(v => $"{v}.md"), .. Pages.Select(p => p.FileName)]; + return string.Join('\n', files.Select(f => $" - file: {f}").Prepend("toc:")) + "\n"; + } - private static string GenerateRootIndex(IReadOnlyList products) + private static string GenerateRootIndex(IReadOnlyList products) { var lines = new List { @@ -74,13 +88,51 @@ private static string GenerateRootIndex(IReadOnlyList products) "", "A scheduled workflow rebuilds it whenever a new build candidate is cut or a SNAPSHOT advances.", "", + UnreleasedNotice, + "", "## Products", "" }; - lines.AddRange(products.Select(p => $"- [{p.DisplayName}]({p.Id}/index.md)")); + lines.AddRange(products.Select(p => $"- [{p.Product.DisplayName}]({p.Product.Id}/index.md)")); return string.Join('\n', lines) + "\n"; } + private static string GenerateProductIndex(Product product, IReadOnlyList versions) => + string.Join( + '\n', + new[] + { + "---", + $"navigation_title: {product.DisplayName}", + "---", + "", + $"# {product.DisplayName} release notes", + "", + UnreleasedNotice, + "", + "## Versions", + "" + }.Concat(versions.Select(v => $"- [{v}]({v}.md)")).Append("") + ); + + private static string GenerateVersionPage(Product product, string version) => + string.Join( + '\n', + "---", + $"navigation_title: {version}", + "---", + "", + $"# {product.DisplayName} {version}", + "", + UnreleasedNotice, + "", + ":::{changelog} /" + BundlesPath(product.Id), + $":version: {version}", + VersionOptions, + ":::", + "" + ); + private static string GeneratePage(Product product, (string FileName, string NavigationTitle, string Heading, string Options) page) => string.Join( '\n', @@ -90,6 +142,8 @@ private static string GeneratePage(Product product, (string FileName, string Nav "", $"# {product.DisplayName} {page.Heading}", "", + UnreleasedNotice, + "", ":::{changelog} /" + BundlesPath(product.Id), page.Options, ":::", diff --git a/tests/Elastic.Documentation.Build.Tests/UnifiedReleaseNotesTests.cs b/tests/Elastic.Documentation.Build.Tests/UnifiedReleaseNotesTests.cs index fc4539854d..147c1addfc 100644 --- a/tests/Elastic.Documentation.Build.Tests/UnifiedReleaseNotesTests.cs +++ b/tests/Elastic.Documentation.Build.Tests/UnifiedReleaseNotesTests.cs @@ -103,18 +103,25 @@ private UnifiedReleaseNotesBuildService Service() ); } + private static IReadOnlyDictionary GenerateFiles() => + UnifiedReleaseNotesDocset.Generate([ + .. UnifiedReleaseNotesDocset.SelectProducts(Products).Select(p => new ProductVersions(p, ["9.6.0", "9.5.6"])) + ]); + [Test] public void SelectProducts_MixedReleaseNotesPaths_KeepsOnlyDraProductsInIdOrder() => UnifiedReleaseNotesDocset.SelectProducts(Products).Select(p => p.Id).Should().Equal("elasticsearch", "kibana"); [Test] - public void Generate_DraProducts_CreatesFourPagesAndTocPerProduct() + public void Generate_DraProducts_CreatesAPagePerVersionAndTheSharedPagesAndTocPerProduct() { - var files = UnifiedReleaseNotesDocset.Generate(UnifiedReleaseNotesDocset.SelectProducts(Products)); + var files = GenerateFiles(); foreach (var product in new[] { "elasticsearch", "kibana" }) { files.Should().ContainKey($"release-notes/{product}/index.md"); + files.Should().ContainKey($"release-notes/{product}/9.6.0.md"); + files.Should().ContainKey($"release-notes/{product}/9.5.6.md"); files.Should().ContainKey($"release-notes/{product}/known-issues.md"); files.Should().ContainKey($"release-notes/{product}/breaking-changes.md"); files.Should().ContainKey($"release-notes/{product}/deprecations.md"); @@ -127,7 +134,7 @@ public void Generate_DraProducts_CreatesFourPagesAndTocPerProduct() [Test] public void Generate_Docset_DeclaresTheTocWithProductsInOrder() { - var files = UnifiedReleaseNotesDocset.Generate(UnifiedReleaseNotesDocset.SelectProducts(Products)); + var files = GenerateFiles(); files["docset.yml"] .Should() @@ -140,21 +147,49 @@ public void Generate_Docset_DeclaresTheTocWithProductsInOrder() [Test] public void Generate_ProductPages_PointTheChangelogDirectiveAtTheProductBundles() { - var files = UnifiedReleaseNotesDocset.Generate(UnifiedReleaseNotesDocset.SelectProducts(Products)); + var files = GenerateFiles(); files["release-notes/elasticsearch/index.md"].Should().Contain("# Elasticsearch release notes"); - files["release-notes/elasticsearch/index.md"] + files["release-notes/elasticsearch/index.md"].Should().Contain("- [9.6.0](9.6.0.md)\n- [9.5.6](9.5.6.md)"); + files["release-notes/elasticsearch/9.6.0.md"] .Should() - .Contain(":::{changelog} /changelog/bundles/elasticsearch\n:highlights:\n:::"); + .Contain(":::{changelog} /changelog/bundles/elasticsearch\n:version: 9.6.0\n:highlights:\n:subsections:\n:::"); + files["release-notes/elasticsearch/9.6.0.md"].Should().Contain("# Elasticsearch 9.6.0"); files["release-notes/kibana/breaking-changes.md"].Should().Contain(":type: breaking-change\n:dropdowns:"); files["release-notes/kibana/known-issues.md"].Should().Contain(":type: known-issue"); files["release-notes/kibana/deprecations.md"].Should().Contain(":type: deprecation"); } + [Test] + public void Generate_ProductPages_AreNamedInTheNavigationAfterTheProductAndTheVersion() + { + var files = GenerateFiles(); + + files["release-notes/kibana/9.6.0.md"].Should().Contain("navigation_title: 9.6.0\n"); + files["release-notes/kibana/toc.yml"] + .Should() + .Be( + "toc:\n - file: index.md\n - file: 9.6.0.md\n - file: 9.5.6.md\n - file: known-issues.md\n - file: breaking-changes.md\n - file: deprecations.md\n" + ); + + files["release-notes/kibana/index.md"].Should().Contain("navigation_title: Kibana\n"); + files["release-notes/kibana/known-issues.md"].Should().Contain("navigation_title: Known issues\n"); + files["release-notes/index.md"].Should().Contain("navigation_title: Release notes\n"); + } + + [Test] + public void Generate_EveryPage_WarnsThatTheVersionsAreUnreleased() + { + var files = GenerateFiles(); + + foreach (var (path, content) in files.Where(f => f.Key.EndsWith(".md", StringComparison.Ordinal))) + content.Should().Contain(":::{warning}\nThis is a preview of an unreleased version.", $"{path} shows unreleased versions"); + } + [Test] public void Generate_RootIndex_LinksEveryProductByDisplayName() { - var files = UnifiedReleaseNotesDocset.Generate(UnifiedReleaseNotesDocset.SelectProducts(Products)); + var files = GenerateFiles(); files["release-notes/index.md"].Should().Contain("- [Elasticsearch](elasticsearch/index.md)"); files["release-notes/index.md"].Should().Contain("- [Kibana](kibana/index.md)"); @@ -180,11 +215,20 @@ public async Task Build_BundlesForEveryProduct_WritesAPageSetPerProduct() .Exists(_fileSystem.Path.Join(output, "release-notes", "kibana", "breaking-changes", "index.html")) .Should() .BeTrue(); + _fileSystem + .File + .ReadAllText(_fileSystem.Path.Join(output, "release-notes", "elasticsearch", "9.5.0", "index.html")) + .Should() + .Contain("Elasticsearch unified change") + .And + .Contain("This is a preview of an unreleased version"); _fileSystem .File .ReadAllText(_fileSystem.Path.Join(output, "release-notes", "elasticsearch", "index.html")) .Should() - .Contain("Elasticsearch unified change"); + .Contain("9.5.0") + .And + .NotContain("Elasticsearch unified change"); _fileSystem.Directory.Exists(_fileSystem.Path.Join(output, "release-notes", "auditbeat")).Should().BeFalse(); } @@ -289,7 +333,7 @@ public async Task Build_BundleWithAmendSidecar_RendersTheAmendedEntries() succeeded.Should().BeTrue(); var html = _fileSystem.File.ReadAllText( - _fileSystem.Path.Join(_temp.FullName, "site", "release-notes", "elasticsearch", "index.html") + _fileSystem.Path.Join(_temp.FullName, "site", "release-notes", "elasticsearch", "9.5.0", "index.html") ); html.Should().Contain("Original change"); html.Should().Contain("Change added by the amend"); From a6c5fe386cb7f867e6b14f8b02e07f4742ded13f Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Fri, 16 Oct 2026 15:34:35 +0200 Subject: [PATCH 2/2] Match changelog bundle versions regardless of a leading 'v' A bundle targeting a release tag like v9.5.0 generates a version page filtered by :version: 9.5.0, but ChangelogVersionMatch compared the bundle's target and file name without stripping 'v' from either side. With a product-prefixed bundle file name, neither side matched, so the version page silently omitted all of its release notes and warned. Co-Authored-By: Claude Co-authored-by: Cursor --- .../ReleaseNotes/ChangelogVersionMatch.cs | 15 +++++--- .../UnifiedReleaseNotesTests.cs | 37 +++++++++++++++++++ 2 files changed, 46 insertions(+), 6 deletions(-) diff --git a/src/Elastic.Documentation/ReleaseNotes/ChangelogVersionMatch.cs b/src/Elastic.Documentation/ReleaseNotes/ChangelogVersionMatch.cs index f05fa73531..4c59ebbcc0 100644 --- a/src/Elastic.Documentation/ReleaseNotes/ChangelogVersionMatch.cs +++ b/src/Elastic.Documentation/ReleaseNotes/ChangelogVersionMatch.cs @@ -7,8 +7,9 @@ namespace Elastic.Documentation.ReleaseNotes; /// /// Shared matching rule for the changelog directive's :version: filter. A bundle /// matches a requested version when the requested value equals either the bundle's declared target -/// or its file name (with or without extension), compared case-insensitively. Kept in one place so -/// the directive's post-load filter and the CDN fetcher's download-time filter agree. +/// or its file name (with or without extension), compared case-insensitively and ignoring a leading +/// v on either side (release tags are v9.5.0; generated version pages are 9.5.0). +/// Kept in one place so the directive's post-load filter and the CDN fetcher's download-time filter agree. /// public static class ChangelogVersionMatch { @@ -21,16 +22,18 @@ public static bool Matches(string requested, string? target, string? file) if (string.IsNullOrWhiteSpace(requested)) return true; - var value = requested.Trim(); + var value = StripLeadingV(requested.Trim()); - if (!string.IsNullOrWhiteSpace(target) && string.Equals(target.Trim(), value, StringComparison.OrdinalIgnoreCase)) + if (!string.IsNullOrWhiteSpace(target) && string.Equals(StripLeadingV(target.Trim()), value, StringComparison.OrdinalIgnoreCase)) return true; if (string.IsNullOrWhiteSpace(file)) return false; var name = Path.GetFileName(file); - return string.Equals(name, value, StringComparison.OrdinalIgnoreCase) - || string.Equals(Path.GetFileNameWithoutExtension(file), value, StringComparison.OrdinalIgnoreCase); + return string.Equals(StripLeadingV(name), value, StringComparison.OrdinalIgnoreCase) + || string.Equals(StripLeadingV(Path.GetFileNameWithoutExtension(file)), value, StringComparison.OrdinalIgnoreCase); } + + private static string StripLeadingV(string version) => version.TrimStart('v', 'V'); } diff --git a/tests/Elastic.Documentation.Build.Tests/UnifiedReleaseNotesTests.cs b/tests/Elastic.Documentation.Build.Tests/UnifiedReleaseNotesTests.cs index 147c1addfc..b23b378c18 100644 --- a/tests/Elastic.Documentation.Build.Tests/UnifiedReleaseNotesTests.cs +++ b/tests/Elastic.Documentation.Build.Tests/UnifiedReleaseNotesTests.cs @@ -261,6 +261,43 @@ public async Task Build_NoProductHasBundles_FailsWithoutWritingASite() _fileSystem.Directory.Exists(_fileSystem.Path.Join(_temp.FullName, "site")).Should().BeFalse(); } + [Test] + public async Task Build_MultipleBundleVersionsSuppliedOutOfOrder_EachVersionPageHasOnlyItsOwnEntriesAndNavigationIsNewestFirst() + { + // Written oldest-first, and with the 'v' prefix a release tag actually has, to prove that + // neither enumeration order nor the target/filename version mismatch affects the result. + _ = WriteBundle("elasticsearch", "elasticsearch-elasticsearch-v9.5.0.yaml", "v9.5.0", "Elasticsearch 9.5.0 change"); + _ = WriteBundle("elasticsearch", "elasticsearch-elasticsearch-v9.6.0.yaml", "v9.6.0", "Elasticsearch 9.6.0 change"); + var collector = new DiagnosticsCollector([]); + + var succeeded = await Service().Build(collector, Options("v9.5.0", "v9.6.0"), TestContext.Current!.Execution.CancellationToken); + + succeeded.Should().BeTrue(); + collector + .Warnings + .Should() + .Be(0, "each version page's :version: filter should match its own bundle despite the bundle target's 'v' prefix"); + + var output = _fileSystem.Path.Join(_temp.FullName, "site", "release-notes", "elasticsearch"); + _fileSystem + .File + .ReadAllText(_fileSystem.Path.Join(output, "9.5.0", "index.html")) + .Should() + .Contain("Elasticsearch 9.5.0 change") + .And + .NotContain("Elasticsearch 9.6.0 change"); + _fileSystem + .File + .ReadAllText(_fileSystem.Path.Join(output, "9.6.0", "index.html")) + .Should() + .Contain("Elasticsearch 9.6.0 change") + .And + .NotContain("Elasticsearch 9.5.0 change"); + + var index = _fileSystem.File.ReadAllText(_fileSystem.Path.Join(output, "index.html")); + index.IndexOf("9.6.0", StringComparison.Ordinal).Should().BeLessThan(index.IndexOf("9.5.0", StringComparison.Ordinal)); + } + [Test] public async Task Build_ExpectedVersionMissingForAProduct_StillBuilds() {