From 1a78ddffcdce4e05f25bfbda7ac5ef97e9640a87 Mon Sep 17 00:00:00 2001 From: Felipe Cotti Date: Thu, 9 Apr 2026 18:20:10 -0300 Subject: [PATCH 1/3] Add support for release-date field --- docs/cli/changelog/bundle.md | 19 +++ docs/syntax/changelog.md | 11 +- .../ReleaseNotes/Bundle.cs | 6 + .../ReleaseNotes/BundleLoader.cs | 14 +- .../ReleaseNotes/ReleaseNotesSerialization.cs | 2 + .../ReleaseNotes/Bundle.cs | 7 + .../Changelog/ChangelogInlineRenderer.cs | 12 +- .../Asciidoc/ChangelogAsciidocRenderer.cs | 7 + .../Rendering/ChangelogRenderContext.cs | 5 + .../Rendering/ChangelogRenderingService.cs | 26 ++- .../Markdown/IndexMarkdownRenderer.cs | 7 + .../BundleLoading/BundleLoaderTests.cs | 159 ++++++++++++++++++ .../Directives/ChangelogBasicTests.cs | 109 ++++++++++++ 13 files changed, 375 insertions(+), 9 deletions(-) diff --git a/docs/cli/changelog/bundle.md b/docs/cli/changelog/bundle.md index 2a62695af..2b6e38f52 100644 --- a/docs/cli/changelog/bundle.md +++ b/docs/cli/changelog/bundle.md @@ -388,6 +388,25 @@ docs-builder changelog bundle \ --description "Elasticsearch {version} includes performance improvements. Download: https://github.com/{owner}/{repo}/releases/tag/v{version}" ``` +### Bundle with release date + +You can add a `release-date` field directly to a bundle YAML file. This field is optional and purely informative for end-users. It is especially useful for components released outside the usual stack lifecycle, such as APM agents and EDOT agents. + +```yaml +products: + - product: apm-agent-dotnet + target: 1.34.0 +release-date: "April 9, 2026" +description: | + This release includes tracing improvements and bug fixes. +entries: + - file: + name: tracing-improvement.yaml + checksum: abc123 +``` + +When the bundle is rendered (by the `changelog render` command or `{changelog}` directive), the release date appears immediately after the version heading as italicized text: `_Released: April 9, 2026_`. + ## Profile-based examples When the changelog configuration file defines `bundle.profiles`, you can use those profiles with the `changelog bundle` command. diff --git a/docs/syntax/changelog.md b/docs/syntax/changelog.md index dfada1db0..ca2128c6d 100644 --- a/docs/syntax/changelog.md +++ b/docs/syntax/changelog.md @@ -142,10 +142,11 @@ For full syntax, refer to the [rules for filtered bundles](/cli/changelog/bundle When bundles contain a `hide-features` field, entries with matching `feature-id` values are automatically filtered out from the rendered output. This allows you to hide unreleased or experimental features without modifying the bundle at render time. ```yaml -# Example bundle with description and hide-features +# Example bundle with release-date, description, and hide-features products: - product: elasticsearch target: 9.3.0 +release-date: "2026-04-09" description: | This release includes new features and bug fixes. @@ -227,11 +228,13 @@ The version is extracted from the first product's `target` field in each bundle ## Rendered output -Each bundle renders as a `## {version}` section with optional description and subsections beneath: +Each bundle renders as a `## {version}` section with optional release date, description, and subsections beneath: ```markdown ## 0.100.0 +_Released: 2026-04-09_ + This release includes new features and bug fixes. Download the release binaries: https://github.com/elastic/elasticsearch/releases/tag/v0.100.0 @@ -246,7 +249,9 @@ Download the release binaries: https://github.com/elastic/elasticsearch/releases ... ``` -Bundle descriptions are rendered when present in the bundle YAML file. The description appears immediately after the version heading but before any entry sections. Descriptions support Markdown formatting including links, lists, and multiple paragraphs. +When present, the `release-date` field is rendered immediately after the version heading as italicized text (e.g., `_Released: 2026-04-09_`). This is purely informative for end-users and is especially useful for components released outside the usual stack lifecycle, such as APM agents and EDOT agents. + +Bundle descriptions are rendered when present in the bundle YAML file. The description appears after the release date (if any) but before any entry sections. Descriptions support Markdown formatting including links, lists, and multiple paragraphs. ### Section types diff --git a/src/Elastic.Documentation.Configuration/ReleaseNotes/Bundle.cs b/src/Elastic.Documentation.Configuration/ReleaseNotes/Bundle.cs index a417ceabe..11e72a87f 100644 --- a/src/Elastic.Documentation.Configuration/ReleaseNotes/Bundle.cs +++ b/src/Elastic.Documentation.Configuration/ReleaseNotes/Bundle.cs @@ -18,6 +18,12 @@ public sealed record BundleDto /// public string? Description { get; set; } /// + /// Optional release date for this bundle. + /// Purely informative; rendered after the release heading. + /// + [YamlMember(Alias = "release-date", ApplyNamingConventions = false)] + public string? ReleaseDate { get; set; } + /// /// Feature IDs that should be hidden when rendering this bundle. /// Entries with matching feature-id values will be commented out in the output. /// diff --git a/src/Elastic.Documentation.Configuration/ReleaseNotes/BundleLoader.cs b/src/Elastic.Documentation.Configuration/ReleaseNotes/BundleLoader.cs index b25a45e1a..f134c46b2 100644 --- a/src/Elastic.Documentation.Configuration/ReleaseNotes/BundleLoader.cs +++ b/src/Elastic.Documentation.Configuration/ReleaseNotes/BundleLoader.cs @@ -229,7 +229,19 @@ private static LoadedBundle MergeBundleGroup(IGrouping gro _ => string.Join("\n\n", descriptions) }; - var mergedData = first.Data with { Description = mergedDescription }; + var releaseDates = bundlesList + .Select(b => b.Data?.ReleaseDate) + .Where(d => !string.IsNullOrEmpty(d)) + .Distinct() + .ToList(); + + var mergedReleaseDate = releaseDates.Count switch + { + 0 => null, + _ => releaseDates[0] + }; + + var mergedData = first.Data with { Description = mergedDescription, ReleaseDate = mergedReleaseDate }; return new LoadedBundle( first.Version, diff --git a/src/Elastic.Documentation.Configuration/ReleaseNotes/ReleaseNotesSerialization.cs b/src/Elastic.Documentation.Configuration/ReleaseNotes/ReleaseNotesSerialization.cs index 671c2d510..f9e0208d3 100644 --- a/src/Elastic.Documentation.Configuration/ReleaseNotes/ReleaseNotesSerialization.cs +++ b/src/Elastic.Documentation.Configuration/ReleaseNotes/ReleaseNotesSerialization.cs @@ -136,6 +136,7 @@ public static string SerializeBundle(Bundle bundle) { Products = dto.Products?.Select(ToBundledProduct).ToList() ?? [], Description = dto.Description, + ReleaseDate = dto.ReleaseDate, HideFeatures = dto.HideFeatures ?? [], Entries = dto.Entries?.Select(ToBundledEntry).ToList() ?? [] }; @@ -241,6 +242,7 @@ private static ChangelogEntryType ParseEntryType(string? value) { Products = bundle.Products.Select(ToDto).ToList(), Description = bundle.Description, + ReleaseDate = bundle.ReleaseDate, HideFeatures = bundle.HideFeatures.Count > 0 ? bundle.HideFeatures.ToList() : null, Entries = bundle.Entries.Select(ToDto).ToList() }; diff --git a/src/Elastic.Documentation/ReleaseNotes/Bundle.cs b/src/Elastic.Documentation/ReleaseNotes/Bundle.cs index be5bf36ba..f41463b3a 100644 --- a/src/Elastic.Documentation/ReleaseNotes/Bundle.cs +++ b/src/Elastic.Documentation/ReleaseNotes/Bundle.cs @@ -19,6 +19,13 @@ public record Bundle /// public string? Description { get; init; } + /// + /// Optional release date for this bundle. + /// Purely informative for end-users; rendered after the release heading. + /// Useful for components released outside the usual stack lifecycle (e.g., APM/EDOT agents). + /// + public string? ReleaseDate { get; init; } + /// /// Feature IDs that should be hidden when rendering this bundle. /// Entries with matching feature-id values will be commented out in the output. diff --git a/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogInlineRenderer.cs b/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogInlineRenderer.cs index cd055e9d7..da2e4e34d 100644 --- a/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogInlineRenderer.cs +++ b/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogInlineRenderer.cs @@ -79,7 +79,7 @@ private static string RenderSingleBundle( }; var displayVersion = VersionOrDate.FormatDisplayVersion(bundle.Version); - return GenerateMarkdown(displayVersion, titleSlug, bundle.Repo, bundle.Owner, entriesByType, subsections, hideLinks, typeFilter, publishBlocker, bundle.Data?.Description); + return GenerateMarkdown(displayVersion, titleSlug, bundle.Repo, bundle.Owner, entriesByType, subsections, hideLinks, typeFilter, publishBlocker, bundle.Data?.Description, bundle.Data?.ReleaseDate); } /// @@ -153,7 +153,8 @@ private static string GenerateMarkdown( bool hideLinks, ChangelogTypeFilter typeFilter, PublishBlocker? publishBlocker, - string? description = null) + string? description = null, + string? releaseDate = null) { var sb = new StringBuilder(); @@ -177,6 +178,13 @@ private static string GenerateMarkdown( _ = sb.AppendLine(CultureInfo.InvariantCulture, $"## {title}"); + // Add release date if present + if (!string.IsNullOrEmpty(releaseDate)) + { + _ = sb.AppendLine(); + _ = sb.AppendLine(CultureInfo.InvariantCulture, $"_Released: {releaseDate}_"); + } + // Add description if present if (!string.IsNullOrEmpty(description)) { diff --git a/src/services/Elastic.Changelog/Rendering/Asciidoc/ChangelogAsciidocRenderer.cs b/src/services/Elastic.Changelog/Rendering/Asciidoc/ChangelogAsciidocRenderer.cs index edce4ad1e..531dec466 100644 --- a/src/services/Elastic.Changelog/Rendering/Asciidoc/ChangelogAsciidocRenderer.cs +++ b/src/services/Elastic.Changelog/Rendering/Asciidoc/ChangelogAsciidocRenderer.cs @@ -32,6 +32,13 @@ public async Task RenderAsciidoc(ChangelogRenderContext context, Cancel ctx) _ = sb.AppendLine(InvariantCulture, $"== {context.Title}"); _ = sb.AppendLine(); + // Add release date if present + if (!string.IsNullOrEmpty(context.BundleReleaseDate)) + { + _ = sb.AppendLine(InvariantCulture, $"_Released: {context.BundleReleaseDate}_"); + _ = sb.AppendLine(); + } + // Add description if present if (!string.IsNullOrEmpty(context.BundleDescription)) { diff --git a/src/services/Elastic.Changelog/Rendering/ChangelogRenderContext.cs b/src/services/Elastic.Changelog/Rendering/ChangelogRenderContext.cs index a2f0d5633..ddd7a7653 100644 --- a/src/services/Elastic.Changelog/Rendering/ChangelogRenderContext.cs +++ b/src/services/Elastic.Changelog/Rendering/ChangelogRenderContext.cs @@ -30,4 +30,9 @@ public record ChangelogRenderContext /// Optional bundle-level introductory description. Only set when there's a single bundle with a description (MVP approach). /// public string? BundleDescription { get; init; } + /// + /// Optional release date for this bundle. Purely informative for end-users. + /// Only set when there's a single bundle with a release date (MVP approach). + /// + public string? BundleReleaseDate { get; init; } } diff --git a/src/services/Elastic.Changelog/Rendering/ChangelogRenderingService.cs b/src/services/Elastic.Changelog/Rendering/ChangelogRenderingService.cs index c78d4ba27..496ec4780 100644 --- a/src/services/Elastic.Changelog/Rendering/ChangelogRenderingService.cs +++ b/src/services/Elastic.Changelog/Rendering/ChangelogRenderingService.cs @@ -152,8 +152,26 @@ Cancel ctx renderDescription = bundleDescriptions[0]; } + // Extract release dates from bundles for MVP support + var bundleReleaseDates = validationResult.Bundles + .Select(b => b.Data.ReleaseDate) + .Where(d => !string.IsNullOrEmpty(d)) + .ToList(); + + string? renderReleaseDate = null; + if (bundleReleaseDates.Count > 1) + { + collector.EmitWarning(string.Empty, + $"Multiple bundles contain release dates ({bundleReleaseDates.Count} found). " + + "Multi-bundle release date support is not yet implemented. Release dates will be skipped."); + } + else if (bundleReleaseDates.Count == 1) + { + renderReleaseDate = bundleReleaseDates[0]; + } + // Build render context - var context = BuildRenderContext(input, outputSetup, resolvedResult, combinedHideFeatures, config, renderDescription); + var context = BuildRenderContext(input, outputSetup, resolvedResult, combinedHideFeatures, config, renderDescription, renderReleaseDate); // Validate entry types if (!ValidateEntryTypes(collector, resolvedResult.Entries, config.Types)) @@ -266,7 +284,8 @@ private static ChangelogRenderContext BuildRenderContext( ResolvedEntriesResult resolved, HashSet featureIdsToHide, ChangelogConfiguration? config, - string? description = null) + string? description = null, + string? releaseDate = null) { // Group entries by type var entriesByType = resolved.Entries @@ -308,7 +327,8 @@ private static ChangelogRenderContext BuildRenderContext( EntryToOwner = entryToOwner, EntryToHideLinks = entryToHideLinks, Configuration = config, - BundleDescription = description + BundleDescription = description, + BundleReleaseDate = releaseDate }; } diff --git a/src/services/Elastic.Changelog/Rendering/Markdown/IndexMarkdownRenderer.cs b/src/services/Elastic.Changelog/Rendering/Markdown/IndexMarkdownRenderer.cs index 3d9b19234..a38b78527 100644 --- a/src/services/Elastic.Changelog/Rendering/Markdown/IndexMarkdownRenderer.cs +++ b/src/services/Elastic.Changelog/Rendering/Markdown/IndexMarkdownRenderer.cs @@ -52,6 +52,13 @@ public override async Task RenderAsync(ChangelogRenderContext context, Cancel ct var sb = new StringBuilder(); _ = sb.AppendLine(InvariantCulture, $"## {context.Title} [{context.Repo}-release-notes-{context.TitleSlug}]"); + // Add release date if present + if (!string.IsNullOrEmpty(context.BundleReleaseDate)) + { + _ = sb.AppendLine(); + _ = sb.AppendLine(InvariantCulture, $"_Released: {context.BundleReleaseDate}_"); + } + // Add description if present if (!string.IsNullOrEmpty(context.BundleDescription)) { diff --git a/tests/Elastic.Changelog.Tests/Changelogs/BundleLoading/BundleLoaderTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/BundleLoading/BundleLoaderTests.cs index 3887682e1..da7ea258e 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/BundleLoading/BundleLoaderTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/BundleLoading/BundleLoaderTests.cs @@ -1184,6 +1184,165 @@ public void LoadBundles_DescriptionCanBeNull() _warnings.Should().BeEmpty(); } + [Fact] + public void LoadBundles_ReleaseDateSerializesAndDeserializesCorrectly() + { + // Arrange - Test round-trip serialization of release-date field + var bundlesFolder = "/docs/changelog/bundles"; + _fileSystem.Directory.CreateDirectory(bundlesFolder); + + var originalBundle = new Bundle + { + Products = + [ + new BundledProduct { ProductId = "apm-agent-dotnet", Target = "1.34.0" } + ], + ReleaseDate = "2026-04-09", + Entries = + [ + new BundledEntry + { + Title = "Test feature", + Type = ChangelogEntryType.Feature, + File = new BundledFile { Name = "test.yaml", Checksum = "abc123" } + } + ] + }; + + var serializedYaml = ReleaseNotesSerialization.SerializeBundle(originalBundle); + _fileSystem.File.WriteAllText($"{bundlesFolder}/1.34.0.yaml", serializedYaml); + + var service = CreateService(); + + // Act + var bundles = service.LoadBundles(bundlesFolder, EmitWarning); + + // Assert + bundles.Should().HaveCount(1); + bundles[0].Data.ReleaseDate.Should().Be("2026-04-09"); + _warnings.Should().BeEmpty(); + } + + [Fact] + public void LoadBundles_ReleaseDateCanBeNull() + { + // Arrange - Test that null release-date is handled correctly + var bundlesFolder = "/docs/changelog/bundles"; + _fileSystem.Directory.CreateDirectory(bundlesFolder); + + var originalBundle = new Bundle + { + Products = + [ + new BundledProduct { ProductId = "elasticsearch", Target = "9.3.0" } + ], + ReleaseDate = null, + Entries = + [ + new BundledEntry + { + Title = "Test feature", + Type = ChangelogEntryType.Feature, + File = new BundledFile { Name = "test.yaml", Checksum = "abc123" } + } + ] + }; + + var serializedYaml = ReleaseNotesSerialization.SerializeBundle(originalBundle); + _fileSystem.File.WriteAllText($"{bundlesFolder}/9.3.0.yaml", serializedYaml); + + var service = CreateService(); + + // Act + var bundles = service.LoadBundles(bundlesFolder, EmitWarning); + + // Assert + bundles.Should().HaveCount(1); + bundles[0].Data.ReleaseDate.Should().BeNull(); + _warnings.Should().BeEmpty(); + } + + [Fact] + public void LoadBundles_ReleaseDateFromYaml_ParsedCorrectly() + { + // Arrange - Test loading release-date from raw YAML + var bundlesFolder = "/docs/changelog/bundles"; + _fileSystem.Directory.CreateDirectory(bundlesFolder); + + // language=yaml + var bundleContent = + """ + products: + - product: apm-agent-dotnet + target: 1.34.0 + release-date: "April 9, 2026" + entries: + - title: Test feature + type: feature + prs: + - "100" + """; + _fileSystem.File.WriteAllText($"{bundlesFolder}/1.34.0.yaml", bundleContent); + + var service = CreateService(); + + // Act + var bundles = service.LoadBundles(bundlesFolder, EmitWarning); + + // Assert + bundles.Should().HaveCount(1); + bundles[0].Data.ReleaseDate.Should().Be("April 9, 2026"); + _warnings.Should().BeEmpty(); + } + + [Fact] + public void MergeBundlesByTarget_ReleaseDatePreserved() + { + // Arrange - Two bundles with same target, one has release-date + var bundlesFolder = "/docs/changelog/bundles"; + _fileSystem.Directory.CreateDirectory(bundlesFolder); + + // language=yaml + var bundle1 = + """ + products: + - product: elasticsearch + target: 9.3.0 + release-date: "2026-04-09" + entries: + - title: Feature from ES + type: feature + prs: + - "100" + """; + + // language=yaml + var bundle2 = + """ + products: + - product: kibana + target: 9.3.0 + entries: + - title: Feature from Kibana + type: feature + prs: + - "200" + """; + + _fileSystem.File.WriteAllText($"{bundlesFolder}/elasticsearch-9.3.0.yaml", bundle1); + _fileSystem.File.WriteAllText($"{bundlesFolder}/kibana-9.3.0.yaml", bundle2); + + var service = CreateService(); + var loaded = service.LoadBundles(bundlesFolder, EmitWarning); + + // Act + var merged = service.MergeBundlesByTarget(loaded); + + // Assert + merged.Should().HaveCount(1); + merged[0].Data.ReleaseDate.Should().Be("2026-04-09"); + } + [Fact] public void LoadedBundle_HideFeatures_ExposedFromBundleData() { diff --git a/tests/Elastic.Markdown.Tests/Directives/ChangelogBasicTests.cs b/tests/Elastic.Markdown.Tests/Directives/ChangelogBasicTests.cs index 0ea1b5ba1..d6626dad4 100644 --- a/tests/Elastic.Markdown.Tests/Directives/ChangelogBasicTests.cs +++ b/tests/Elastic.Markdown.Tests/Directives/ChangelogBasicTests.cs @@ -610,3 +610,112 @@ public void RendersDescriptionText() => public void DoesNotConcatenateTitleAndDescriptionWithoutSeparator() => Html.Should().NotContain("allowlist.This PR introduces"); } + +/// +/// Verifies that when a bundle has a release-date field, it is rendered in the output. +/// +public class ChangelogReleaseDateTests : DirectiveTest +{ + public ChangelogReleaseDateTests(ITestOutputHelper output) : base(output, + // language=markdown + """ + :::{changelog} + ::: + """) => FileSystem.AddFile("docs/changelog/bundles/1.34.0.yaml", new MockFileData( + // language=yaml + """ + products: + - product: apm-agent-dotnet + target: 1.34.0 + release-date: "April 9, 2026" + entries: + - title: Add tracing improvements + type: feature + products: + - product: apm-agent-dotnet + target: 1.34.0 + prs: + - "500" + """)); + + [Fact] + public void RendersReleaseDate() => + Html.Should().Contain("Released: April 9, 2026"); + + [Fact] + public void RendersEntries() => + Html.Should().Contain("Add tracing improvements"); +} + +/// +/// Verifies that when a bundle has no release-date field, no "Released:" text appears. +/// +public class ChangelogNoReleaseDateTests : DirectiveTest +{ + public ChangelogNoReleaseDateTests(ITestOutputHelper output) : base(output, + // language=markdown + """ + :::{changelog} + ::: + """) => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( + // language=yaml + """ + products: + - product: elasticsearch + target: 9.3.0 + entries: + - title: New feature + type: feature + products: + - product: elasticsearch + target: 9.3.0 + prs: + - "100" + """)); + + [Fact] + public void DoesNotRenderReleaseDate() => + Html.Should().NotContain("Released:"); +} + +/// +/// Verifies that both release-date and description render together. +/// +public class ChangelogReleaseDateWithDescriptionTests : DirectiveTest +{ + public ChangelogReleaseDateWithDescriptionTests(ITestOutputHelper output) : base(output, + // language=markdown + """ + :::{changelog} + ::: + """) => FileSystem.AddFile("docs/changelog/bundles/1.34.0.yaml", new MockFileData( + // language=yaml + """ + products: + - product: apm-agent-dotnet + target: 1.34.0 + release-date: "2026-04-09" + description: | + This release includes tracing improvements and bug fixes. + entries: + - title: Add tracing improvements + type: feature + products: + - product: apm-agent-dotnet + target: 1.34.0 + prs: + - "500" + """)); + + [Fact] + public void RendersReleaseDate() => + Html.Should().Contain("Released: 2026-04-09"); + + [Fact] + public void RendersDescription() => + Html.Should().Contain("This release includes tracing improvements and bug fixes."); + + [Fact] + public void RendersEntries() => + Html.Should().Contain("Add tracing improvements"); +} From cb0cc925487e8e0722638010a11b2af902ff17e6 Mon Sep 17 00:00:00 2001 From: Felipe Cotti Date: Thu, 9 Apr 2026 18:40:51 -0300 Subject: [PATCH 2/3] Add Distinct() checking --- .../Elastic.Changelog/Rendering/ChangelogRenderingService.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/services/Elastic.Changelog/Rendering/ChangelogRenderingService.cs b/src/services/Elastic.Changelog/Rendering/ChangelogRenderingService.cs index 496ec4780..74de630f5 100644 --- a/src/services/Elastic.Changelog/Rendering/ChangelogRenderingService.cs +++ b/src/services/Elastic.Changelog/Rendering/ChangelogRenderingService.cs @@ -137,6 +137,7 @@ Cancel ctx var bundleDescriptions = validationResult.Bundles .Select(b => b.Data.Description) .Where(d => !string.IsNullOrEmpty(d)) + .Distinct() .ToList(); // MVP: Check for multiple descriptions and warn @@ -156,6 +157,7 @@ Cancel ctx var bundleReleaseDates = validationResult.Bundles .Select(b => b.Data.ReleaseDate) .Where(d => !string.IsNullOrEmpty(d)) + .Distinct() .ToList(); string? renderReleaseDate = null; From 27343d236a2293e51bfb5a62f847c9c7a081b0cf Mon Sep 17 00:00:00 2001 From: Felipe Cotti Date: Thu, 9 Apr 2026 20:32:04 -0300 Subject: [PATCH 3/3] Use DateOnly internally --- .../ReleaseNotes/BundleLoader.cs | 5 ++- .../ReleaseNotes/ReleaseNotesSerialization.cs | 10 ++++- .../ReleaseNotes/Bundle.cs | 3 +- .../Changelog/ChangelogInlineRenderer.cs | 6 +-- .../Asciidoc/ChangelogAsciidocRenderer.cs | 4 +- .../Rendering/ChangelogRenderContext.cs | 4 +- .../Rendering/ChangelogRenderingService.cs | 7 ++-- .../Markdown/IndexMarkdownRenderer.cs | 4 +- .../BundleLoading/BundleLoaderTests.cs | 42 ++++++++++++++++--- .../Directives/ChangelogBasicTests.cs | 4 +- 10 files changed, 64 insertions(+), 25 deletions(-) diff --git a/src/Elastic.Documentation.Configuration/ReleaseNotes/BundleLoader.cs b/src/Elastic.Documentation.Configuration/ReleaseNotes/BundleLoader.cs index f134c46b2..c3c1420fa 100644 --- a/src/Elastic.Documentation.Configuration/ReleaseNotes/BundleLoader.cs +++ b/src/Elastic.Documentation.Configuration/ReleaseNotes/BundleLoader.cs @@ -231,13 +231,14 @@ private static LoadedBundle MergeBundleGroup(IGrouping gro var releaseDates = bundlesList .Select(b => b.Data?.ReleaseDate) - .Where(d => !string.IsNullOrEmpty(d)) + .Where(d => d.HasValue) + .Select(d => d!.Value) .Distinct() .ToList(); var mergedReleaseDate = releaseDates.Count switch { - 0 => null, + 0 => (DateOnly?)null, _ => releaseDates[0] }; diff --git a/src/Elastic.Documentation.Configuration/ReleaseNotes/ReleaseNotesSerialization.cs b/src/Elastic.Documentation.Configuration/ReleaseNotes/ReleaseNotesSerialization.cs index f9e0208d3..6f0690ec9 100644 --- a/src/Elastic.Documentation.Configuration/ReleaseNotes/ReleaseNotesSerialization.cs +++ b/src/Elastic.Documentation.Configuration/ReleaseNotes/ReleaseNotesSerialization.cs @@ -2,6 +2,7 @@ // Elasticsearch B.V licenses this file to you under the Apache 2.0 License. // See the LICENSE file in the project root for more information +using System.Globalization; using System.IO.Abstractions; using System.Text.RegularExpressions; using Elastic.Documentation.Configuration.Serialization; @@ -136,7 +137,7 @@ public static string SerializeBundle(Bundle bundle) { Products = dto.Products?.Select(ToBundledProduct).ToList() ?? [], Description = dto.Description, - ReleaseDate = dto.ReleaseDate, + ReleaseDate = ParseReleaseDate(dto.ReleaseDate), HideFeatures = dto.HideFeatures ?? [], Entries = dto.Entries?.Select(ToBundledEntry).ToList() ?? [] }; @@ -213,6 +214,11 @@ private static ChangelogEntryType ParseEntryType(string? value) : null; } + private static DateOnly? ParseReleaseDate(string? value) => + DateOnly.TryParseExact(value, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var date) + ? date + : null; + // Reverse mappings (Domain → DTO) for serialization private static ChangelogEntryDto ToDto(ChangelogEntry entry) => new() @@ -242,7 +248,7 @@ private static ChangelogEntryType ParseEntryType(string? value) { Products = bundle.Products.Select(ToDto).ToList(), Description = bundle.Description, - ReleaseDate = bundle.ReleaseDate, + ReleaseDate = bundle.ReleaseDate?.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture), HideFeatures = bundle.HideFeatures.Count > 0 ? bundle.HideFeatures.ToList() : null, Entries = bundle.Entries.Select(ToDto).ToList() }; diff --git a/src/Elastic.Documentation/ReleaseNotes/Bundle.cs b/src/Elastic.Documentation/ReleaseNotes/Bundle.cs index f41463b3a..70c470eb7 100644 --- a/src/Elastic.Documentation/ReleaseNotes/Bundle.cs +++ b/src/Elastic.Documentation/ReleaseNotes/Bundle.cs @@ -23,8 +23,9 @@ public record Bundle /// Optional release date for this bundle. /// Purely informative for end-users; rendered after the release heading. /// Useful for components released outside the usual stack lifecycle (e.g., APM/EDOT agents). + /// Parsed from YYYY-MM-DD format in YAML; serialized back as YYYY-MM-DD. /// - public string? ReleaseDate { get; init; } + public DateOnly? ReleaseDate { get; init; } /// /// Feature IDs that should be hidden when rendering this bundle. diff --git a/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogInlineRenderer.cs b/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogInlineRenderer.cs index da2e4e34d..af282fef5 100644 --- a/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogInlineRenderer.cs +++ b/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogInlineRenderer.cs @@ -154,7 +154,7 @@ private static string GenerateMarkdown( ChangelogTypeFilter typeFilter, PublishBlocker? publishBlocker, string? description = null, - string? releaseDate = null) + DateOnly? releaseDate = null) { var sb = new StringBuilder(); @@ -179,10 +179,10 @@ private static string GenerateMarkdown( _ = sb.AppendLine(CultureInfo.InvariantCulture, $"## {title}"); // Add release date if present - if (!string.IsNullOrEmpty(releaseDate)) + if (releaseDate is { } date) { _ = sb.AppendLine(); - _ = sb.AppendLine(CultureInfo.InvariantCulture, $"_Released: {releaseDate}_"); + _ = sb.AppendLine(CultureInfo.InvariantCulture, $"_Released: {date.ToString("MMMM d, yyyy", CultureInfo.InvariantCulture)}_"); } // Add description if present diff --git a/src/services/Elastic.Changelog/Rendering/Asciidoc/ChangelogAsciidocRenderer.cs b/src/services/Elastic.Changelog/Rendering/Asciidoc/ChangelogAsciidocRenderer.cs index 531dec466..e23554c59 100644 --- a/src/services/Elastic.Changelog/Rendering/Asciidoc/ChangelogAsciidocRenderer.cs +++ b/src/services/Elastic.Changelog/Rendering/Asciidoc/ChangelogAsciidocRenderer.cs @@ -33,9 +33,9 @@ public async Task RenderAsciidoc(ChangelogRenderContext context, Cancel ctx) _ = sb.AppendLine(); // Add release date if present - if (!string.IsNullOrEmpty(context.BundleReleaseDate)) + if (context.BundleReleaseDate is { } releaseDate) { - _ = sb.AppendLine(InvariantCulture, $"_Released: {context.BundleReleaseDate}_"); + _ = sb.AppendLine(InvariantCulture, $"_Released: {releaseDate.ToString("MMMM d, yyyy", InvariantCulture)}_"); _ = sb.AppendLine(); } diff --git a/src/services/Elastic.Changelog/Rendering/ChangelogRenderContext.cs b/src/services/Elastic.Changelog/Rendering/ChangelogRenderContext.cs index ddd7a7653..4fcdfbe20 100644 --- a/src/services/Elastic.Changelog/Rendering/ChangelogRenderContext.cs +++ b/src/services/Elastic.Changelog/Rendering/ChangelogRenderContext.cs @@ -32,7 +32,7 @@ public record ChangelogRenderContext public string? BundleDescription { get; init; } /// /// Optional release date for this bundle. Purely informative for end-users. - /// Only set when there's a single bundle with a release date (MVP approach). + /// Only set when there's a single unique release date across all bundles (MVP approach). /// - public string? BundleReleaseDate { get; init; } + public DateOnly? BundleReleaseDate { get; init; } } diff --git a/src/services/Elastic.Changelog/Rendering/ChangelogRenderingService.cs b/src/services/Elastic.Changelog/Rendering/ChangelogRenderingService.cs index 74de630f5..800d64b93 100644 --- a/src/services/Elastic.Changelog/Rendering/ChangelogRenderingService.cs +++ b/src/services/Elastic.Changelog/Rendering/ChangelogRenderingService.cs @@ -156,11 +156,12 @@ Cancel ctx // Extract release dates from bundles for MVP support var bundleReleaseDates = validationResult.Bundles .Select(b => b.Data.ReleaseDate) - .Where(d => !string.IsNullOrEmpty(d)) + .Where(d => d.HasValue) + .Select(d => d!.Value) .Distinct() .ToList(); - string? renderReleaseDate = null; + DateOnly? renderReleaseDate = null; if (bundleReleaseDates.Count > 1) { collector.EmitWarning(string.Empty, @@ -287,7 +288,7 @@ private static ChangelogRenderContext BuildRenderContext( HashSet featureIdsToHide, ChangelogConfiguration? config, string? description = null, - string? releaseDate = null) + DateOnly? releaseDate = null) { // Group entries by type var entriesByType = resolved.Entries diff --git a/src/services/Elastic.Changelog/Rendering/Markdown/IndexMarkdownRenderer.cs b/src/services/Elastic.Changelog/Rendering/Markdown/IndexMarkdownRenderer.cs index a38b78527..9933eacee 100644 --- a/src/services/Elastic.Changelog/Rendering/Markdown/IndexMarkdownRenderer.cs +++ b/src/services/Elastic.Changelog/Rendering/Markdown/IndexMarkdownRenderer.cs @@ -53,10 +53,10 @@ public override async Task RenderAsync(ChangelogRenderContext context, Cancel ct _ = sb.AppendLine(InvariantCulture, $"## {context.Title} [{context.Repo}-release-notes-{context.TitleSlug}]"); // Add release date if present - if (!string.IsNullOrEmpty(context.BundleReleaseDate)) + if (context.BundleReleaseDate is { } releaseDate) { _ = sb.AppendLine(); - _ = sb.AppendLine(InvariantCulture, $"_Released: {context.BundleReleaseDate}_"); + _ = sb.AppendLine(InvariantCulture, $"_Released: {releaseDate.ToString("MMMM d, yyyy", InvariantCulture)}_"); } // Add description if present diff --git a/tests/Elastic.Changelog.Tests/Changelogs/BundleLoading/BundleLoaderTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/BundleLoading/BundleLoaderTests.cs index da7ea258e..d272a3152 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/BundleLoading/BundleLoaderTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/BundleLoading/BundleLoaderTests.cs @@ -1197,7 +1197,7 @@ public void LoadBundles_ReleaseDateSerializesAndDeserializesCorrectly() [ new BundledProduct { ProductId = "apm-agent-dotnet", Target = "1.34.0" } ], - ReleaseDate = "2026-04-09", + ReleaseDate = new DateOnly(2026, 4, 9), Entries = [ new BundledEntry @@ -1219,14 +1219,13 @@ public void LoadBundles_ReleaseDateSerializesAndDeserializesCorrectly() // Assert bundles.Should().HaveCount(1); - bundles[0].Data.ReleaseDate.Should().Be("2026-04-09"); + bundles[0].Data.ReleaseDate.Should().Be(new DateOnly(2026, 4, 9)); _warnings.Should().BeEmpty(); } [Fact] public void LoadBundles_ReleaseDateCanBeNull() { - // Arrange - Test that null release-date is handled correctly var bundlesFolder = "/docs/changelog/bundles"; _fileSystem.Directory.CreateDirectory(bundlesFolder); @@ -1265,7 +1264,38 @@ public void LoadBundles_ReleaseDateCanBeNull() [Fact] public void LoadBundles_ReleaseDateFromYaml_ParsedCorrectly() { - // Arrange - Test loading release-date from raw YAML + var bundlesFolder = "/docs/changelog/bundles"; + _fileSystem.Directory.CreateDirectory(bundlesFolder); + + // language=yaml + var bundleContent = + """ + products: + - product: apm-agent-dotnet + target: 1.34.0 + release-date: "2026-04-09" + entries: + - title: Test feature + type: feature + prs: + - "100" + """; + _fileSystem.File.WriteAllText($"{bundlesFolder}/1.34.0.yaml", bundleContent); + + var service = CreateService(); + + // Act + var bundles = service.LoadBundles(bundlesFolder, EmitWarning); + + // Assert + bundles.Should().HaveCount(1); + bundles[0].Data.ReleaseDate.Should().Be(new DateOnly(2026, 4, 9)); + _warnings.Should().BeEmpty(); + } + + [Fact] + public void LoadBundles_ReleaseDateInvalidFormat_ParsedAsNull() + { var bundlesFolder = "/docs/changelog/bundles"; _fileSystem.Directory.CreateDirectory(bundlesFolder); @@ -1291,7 +1321,7 @@ public void LoadBundles_ReleaseDateFromYaml_ParsedCorrectly() // Assert bundles.Should().HaveCount(1); - bundles[0].Data.ReleaseDate.Should().Be("April 9, 2026"); + bundles[0].Data.ReleaseDate.Should().BeNull(); _warnings.Should().BeEmpty(); } @@ -1340,7 +1370,7 @@ public void MergeBundlesByTarget_ReleaseDatePreserved() // Assert merged.Should().HaveCount(1); - merged[0].Data.ReleaseDate.Should().Be("2026-04-09"); + merged[0].Data.ReleaseDate.Should().Be(new DateOnly(2026, 4, 9)); } [Fact] diff --git a/tests/Elastic.Markdown.Tests/Directives/ChangelogBasicTests.cs b/tests/Elastic.Markdown.Tests/Directives/ChangelogBasicTests.cs index d6626dad4..4f2b49466 100644 --- a/tests/Elastic.Markdown.Tests/Directives/ChangelogBasicTests.cs +++ b/tests/Elastic.Markdown.Tests/Directives/ChangelogBasicTests.cs @@ -627,7 +627,7 @@ public ChangelogReleaseDateTests(ITestOutputHelper output) : base(output, products: - product: apm-agent-dotnet target: 1.34.0 - release-date: "April 9, 2026" + release-date: "2026-04-09" entries: - title: Add tracing improvements type: feature @@ -709,7 +709,7 @@ This release includes tracing improvements and bug fixes. [Fact] public void RendersReleaseDate() => - Html.Should().Contain("Released: 2026-04-09"); + Html.Should().Contain("Released: April 9, 2026"); [Fact] public void RendersDescription() =>