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/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..b23b378c18 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();
}
@@ -217,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()
{
@@ -289,7 +370,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");