From 1a78ddffcdce4e05f25bfbda7ac5ef97e9640a87 Mon Sep 17 00:00:00 2001 From: Felipe Cotti Date: Thu, 9 Apr 2026 18:20:10 -0300 Subject: [PATCH 01/12] 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 02/12] 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 03/12] 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() => From fc0d32118f378adec39ec549631fb966f9471bcd Mon Sep 17 00:00:00 2001 From: lcawl Date: Fri, 10 Apr 2026 15:51:39 -0700 Subject: [PATCH 04/12] Add bundle release date command options --- config/changelog.example.yml | 5 ++ docs/cli/changelog/bundle.md | 12 +++++ docs/cli/changelog/gh-release.md | 5 ++ docs/contribute/changelog.md | 22 ++++++++ docs/syntax/changelog.md | 2 + .../Changelog/BundleConfiguration.cs | 14 +++++ .../ReleaseNotes/Bundle.cs | 6 +++ .../Changelog/ChangelogInlineRenderer.cs | 9 ++-- .../Bundling/ChangelogBundlingService.cs | 52 ++++++++++++++++++- .../ChangelogConfigurationLoader.cs | 2 + .../GitHub/GitHubReleaseService.cs | 6 ++- .../GitHub/IGitHubReleaseService.cs | 5 ++ .../GitHubReleaseChangelogService.cs | 20 ++++++- .../Asciidoc/ChangelogAsciidocRenderer.cs | 4 +- .../Rendering/ChangelogRenderContext.cs | 6 +++ .../Rendering/ChangelogRenderingService.cs | 16 ++++-- .../Markdown/IndexMarkdownRenderer.cs | 4 +- .../ChangelogConfigurationYaml.cs | 11 ++++ .../docs-builder/Commands/ChangelogCommand.cs | 48 ++++++++++++++++- 19 files changed, 232 insertions(+), 17 deletions(-) diff --git a/config/changelog.example.yml b/config/changelog.example.yml index 82c75e76c..22ca942f3 100644 --- a/config/changelog.example.yml +++ b/config/changelog.example.yml @@ -218,6 +218,9 @@ bundle: # This release includes new features and bug fixes. # # For more information, see the [release notes](https://www.elastic.co/docs/release-notes/product#product-{version}). + # Whether to show release dates in rendered changelog output (default: false) + # When true, release-date fields are displayed as "Released: date" text + # show_release_dates: false # changelog-init-bundle-seed # PR/issue link allowlist: when set (including []), only links to these owner/repo pairs are kept # in bundle output; others are rewritten to '# PRIVATE:' sentinels (requires resolve: true). @@ -255,6 +258,8 @@ bundle: # # description: | # # Elasticsearch {version} includes: # # - Performance improvements + # # Optional: profile-specific release date display setting (overrides bundle.show_release_dates) + # # show_release_dates: false # # - Bug fixes and stability enhancements # # # # Download the release binaries: https://github.com/{owner}/{repo}/releases/tag/v{version} diff --git a/docs/cli/changelog/bundle.md b/docs/cli/changelog/bundle.md index 2b6e38f52..1c93840c3 100644 --- a/docs/cli/changelog/bundle.md +++ b/docs/cli/changelog/bundle.md @@ -124,6 +124,18 @@ The `--input-products` option determines which changelog files are gathered for : This value replaces information that would otherwise be derived from changelogs. : When `rules.bundle.products` per-product overrides are configured, `--output-products` also supplies the product IDs used to choose the **rule context product** (first alphabetically) for Mode 3. To use a different product's rules, run a separate bundle with only that product in `--output-products`. For details, refer to [Single-product rule resolution algorithm](/contribute/changelog.md#changelog-bundle-rule-resolution). +`--no-release-date` +: Optional: Skip auto-population of release date in the bundle. +: By default, bundles are created with a `release-date` field set to today's date (UTC) or the GitHub release published date when using `--release-version`. +: Mutually exclusive with `--release-date`. +: **Not available in profile mode** — use bundle configuration instead. + +`--release-date ` +: Optional: Explicit release date for the bundle in YYYY-MM-DD format. +: Overrides the default auto-population behavior (today's date or GitHub release published date). +: Mutually exclusive with `--no-release-date`. +: **Not available in profile mode** — use bundle configuration instead. + `--owner ` : Optional: The GitHub repository owner, required when pull requests or issues are specified as numbers. : Precedence: `--owner` flag > `bundle.owner` in `changelog.yml` > `elastic`. diff --git a/docs/cli/changelog/gh-release.md b/docs/cli/changelog/gh-release.md index 0c36ec80a..ba09e56c1 100644 --- a/docs/cli/changelog/gh-release.md +++ b/docs/cli/changelog/gh-release.md @@ -38,6 +38,11 @@ docs-builder changelog gh-release [version] [options...] [-h|--help] `--output ` : Optional: Output directory for the generated changelog files. Falls back to `bundle.directory` in `changelog.yml` when not specified. Defaults to `./changelogs`. +`--release-date ` +: Optional: Explicit release date for the bundle in YYYY-MM-DD format. +: By default, the bundle uses the GitHub release's published date. This option overrides that behavior. +: If the GitHub release has no published date, falls back to today's date (UTC). + `--strip-title-prefix` : Optional: Remove square brackets and the text within them from the beginning of pull request titles, and also remove a colon if it follows the closing bracket. : For example, `"[Inference API] New embedding model support"` becomes `"New embedding model support"`. diff --git a/docs/contribute/changelog.md b/docs/contribute/changelog.md index 0bc27acfa..014d253a0 100644 --- a/docs/contribute/changelog.md +++ b/docs/contribute/changelog.md @@ -821,6 +821,28 @@ You can add introductory text to bundles using the `description` field. This tex - `bundle.description`: Default description for all profiles - `bundle.profiles..description`: Profile-specific description (overrides the default) +#### Release date control + +You can control whether release dates appear in rendered changelog output using the `show_release_dates` field. + +**Configuration locations:** + +- `bundle.show_release_dates`: Default setting for all profiles (defaults to `false`) +- `bundle.profiles..show_release_dates`: Profile-specific setting (overrides the default) + +**Behavior:** + +- `false` (default): Release date fields are ignored during rendering, even if present in bundles +- `true`: Release dates are displayed when present (e.g., `_Released: April 9, 2026_`) + +**Auto-population:** + +Release dates are automatically added to bundles when using `changelog bundle` or `changelog gh-release`: + +- **Default**: Uses today's date (UTC) when bundling +- **GitHub releases**: Uses the GitHub release's published date when available +- **Override**: Use `--release-date YYYY-MM-DD` or `--no-release-date` in option mode + **Placeholder support:** Bundle descriptions support these placeholders: diff --git a/docs/syntax/changelog.md b/docs/syntax/changelog.md index ca2128c6d..a87afe4f4 100644 --- a/docs/syntax/changelog.md +++ b/docs/syntax/changelog.md @@ -251,6 +251,8 @@ Download the release binaries: https://github.com/elastic/elasticsearch/releases 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. +The `show_release_dates` setting in `changelog.yml` (boolean, defaults to `false`) controls whether release dates appear in the rendered output. When set to `false`, the `release-date` field is ignored during rendering. + 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/Changelog/BundleConfiguration.cs b/src/Elastic.Documentation.Configuration/Changelog/BundleConfiguration.cs index 5282420b3..6296f4c9c 100644 --- a/src/Elastic.Documentation.Configuration/Changelog/BundleConfiguration.cs +++ b/src/Elastic.Documentation.Configuration/Changelog/BundleConfiguration.cs @@ -51,6 +51,13 @@ public record BundleConfiguration /// public IReadOnlyList? LinkAllowRepos { get; init; } + /// + /// Whether to show release dates in rendered changelog output. + /// When true, bundles with release-date fields will display "Released: date" text. + /// Defaults to false. + /// + public bool ShowReleaseDates { get; init; } + /// /// Named bundle profiles for different release scenarios. /// @@ -111,6 +118,13 @@ public record BundleProfile /// public IReadOnlyList? HideFeatures { get; init; } + /// + /// Whether to show release dates in rendered changelog output for this profile. + /// When provided, overrides the bundle.show_release_dates default. + /// When true, bundles with release-date fields will display "Released: date" text. + /// + public bool? ShowReleaseDates { get; init; } + /// /// Profile source type. When set to "github_release", the profile fetches /// PR references directly from a GitHub release and uses them as the bundle filter. diff --git a/src/Elastic.Documentation/ReleaseNotes/Bundle.cs b/src/Elastic.Documentation/ReleaseNotes/Bundle.cs index 70c470eb7..ccdd4848b 100644 --- a/src/Elastic.Documentation/ReleaseNotes/Bundle.cs +++ b/src/Elastic.Documentation/ReleaseNotes/Bundle.cs @@ -27,6 +27,12 @@ public record Bundle /// public DateOnly? ReleaseDate { get; init; } + /// + /// Whether to show release dates in rendered changelog output for this bundle. + /// When true, the ReleaseDate field (if present) will be displayed as "Released: date" text. + /// + public bool ShowReleaseDates { 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 af282fef5..86f094533 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, bundle.Data?.ReleaseDate); + return GenerateMarkdown(displayVersion, titleSlug, bundle.Repo, bundle.Owner, entriesByType, subsections, hideLinks, typeFilter, publishBlocker, bundle.Data?.Description, bundle.Data?.ReleaseDate, bundle.Data?.ShowReleaseDates ?? false); } /// @@ -154,7 +154,8 @@ private static string GenerateMarkdown( ChangelogTypeFilter typeFilter, PublishBlocker? publishBlocker, string? description = null, - DateOnly? releaseDate = null) + DateOnly? releaseDate = null, + bool showReleaseDates = false) { var sb = new StringBuilder(); @@ -178,8 +179,8 @@ private static string GenerateMarkdown( _ = sb.AppendLine(CultureInfo.InvariantCulture, $"## {title}"); - // Add release date if present - if (releaseDate is { } date) + // Add release date if present and ShowReleaseDates is enabled + if (showReleaseDates && releaseDate is { } date) { _ = sb.AppendLine(); _ = sb.AppendLine(CultureInfo.InvariantCulture, $"_Released: {date.ToString("MMMM d, yyyy", CultureInfo.InvariantCulture)}_"); diff --git a/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs b/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs index 2b4d130a3..9a9e2f522 100644 --- a/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs +++ b/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs @@ -91,6 +91,17 @@ public record BundleChangelogsArguments /// public string? Description { get; init; } + /// + /// Optional explicit release date for the bundle in YYYY-MM-DD format. + /// When provided, overrides auto-population behavior. + /// + public string? ReleaseDate { get; init; } + + /// + /// Whether to show release dates in rendered output. When null, inherits from config/profile. + /// + public bool? ShowReleaseDates { get; init; } + /// /// When non-null (including empty), PR/issue links are filtered to this owner/repo allowlist (from changelog.yml bundle.link_allow_repos). /// @@ -354,6 +365,36 @@ public async Task BundleChangelogs(IDiagnosticsCollector collector, Bundle } } + // Apply release date auto-population and ShowReleaseDates setting + var finalReleaseDate = bundleData.ReleaseDate; // Preserve existing date if present + if (!string.IsNullOrEmpty(input.ReleaseDate)) + { + // Explicit CLI override + if (DateOnly.TryParseExact(input.ReleaseDate, "yyyy-MM-dd", out var parsedDate)) + { + finalReleaseDate = parsedDate; + } + else + { + collector.EmitError(string.Empty, $"Invalid release date format '{input.ReleaseDate}'. Expected YYYY-MM-DD format."); + return false; + } + } + else if (finalReleaseDate == null) + { + // Auto-populate with today's date (UTC) if no existing date + finalReleaseDate = DateOnly.FromDateTime(DateTime.UtcNow); + } + + // Apply ShowReleaseDates setting (input takes precedence over config) + var showReleaseDates = input.ShowReleaseDates ?? config?.Bundle?.ShowReleaseDates ?? false; + + bundleData = bundleData with + { + ReleaseDate = finalReleaseDate, + ShowReleaseDates = showReleaseDates + }; + // Write bundle file await WriteBundleFileAsync(bundleData, outputPath, ctx); @@ -395,6 +436,7 @@ public async Task BundleChangelogs(IDiagnosticsCollector collector, Bundle string? owner = null; string[]? mergedHideFeatures = null; string? profileDescription = null; + bool? profileShowReleaseDates = null; if (config?.Bundle?.Profiles != null && config.Bundle.Profiles.TryGetValue(input.Profile!, out var profile)) { @@ -437,6 +479,9 @@ public async Task BundleChangelogs(IDiagnosticsCollector collector, Bundle owner = profile.Owner ?? config.Bundle.Owner; mergedHideFeatures = profile.HideFeatures?.Count > 0 ? [.. profile.HideFeatures] : null; + // Profile-level ShowReleaseDates takes precedence; fall back to bundle-level default + profileShowReleaseDates = profile.ShowReleaseDates ?? config.Bundle.ShowReleaseDates; + // Handle profile-specific description with placeholder substitution var descriptionTemplate = profile.Description ?? config.Bundle.Description; if (!string.IsNullOrEmpty(descriptionTemplate)) @@ -480,7 +525,8 @@ public async Task BundleChangelogs(IDiagnosticsCollector collector, Bundle Repo = repo, Owner = owner, HideFeatures = mergedHideFeatures, - Description = profileDescription + Description = profileDescription, + ShowReleaseDates = profileShowReleaseDates ?? config?.Bundle?.ShowReleaseDates }; } @@ -507,6 +553,9 @@ private BundleChangelogsArguments ApplyConfigDefaults(BundleChangelogsArguments // Apply description: CLI takes precedence; fall back to bundle-level config default var description = input.Description ?? config.Bundle.Description; + // Apply ShowReleaseDates: CLI takes precedence; fall back to bundle-level config default + var showReleaseDates = input.ShowReleaseDates ?? config.Bundle.ShowReleaseDates; + return input with { Directory = directory, @@ -515,6 +564,7 @@ private BundleChangelogsArguments ApplyConfigDefaults(BundleChangelogsArguments Repo = repo, Owner = owner, Description = description, + ShowReleaseDates = showReleaseDates, LinkAllowRepos = config.Bundle.LinkAllowRepos }; } diff --git a/src/services/Elastic.Changelog/Configuration/ChangelogConfigurationLoader.cs b/src/services/Elastic.Changelog/Configuration/ChangelogConfigurationLoader.cs index f07b82a80..054556500 100644 --- a/src/services/Elastic.Changelog/Configuration/ChangelogConfigurationLoader.cs +++ b/src/services/Elastic.Changelog/Configuration/ChangelogConfigurationLoader.cs @@ -521,6 +521,7 @@ private static PivotConfiguration ConvertPivot(PivotConfigurationYaml yamlPivot) Repo = kvp.Value.Repo, Owner = kvp.Value.Owner, HideFeatures = kvp.Value.HideFeatures?.Values, + ShowReleaseDates = kvp.Value.ShowReleaseDates, Source = kvp.Value.Source }); } @@ -534,6 +535,7 @@ private static PivotConfiguration ConvertPivot(PivotConfigurationYaml yamlPivot) Repo = yaml.Repo, Owner = yaml.Owner, LinkAllowRepos = linkAllowRepos, + ShowReleaseDates = yaml.ShowReleaseDates ?? false, Profiles = profiles }; } diff --git a/src/services/Elastic.Changelog/GitHub/GitHubReleaseService.cs b/src/services/Elastic.Changelog/GitHub/GitHubReleaseService.cs index e9985e16d..c28a0b743 100644 --- a/src/services/Elastic.Changelog/GitHub/GitHubReleaseService.cs +++ b/src/services/Elastic.Changelog/GitHub/GitHubReleaseService.cs @@ -103,7 +103,8 @@ static GitHubReleaseService() Body = releaseData.Body ?? string.Empty, Prerelease = releaseData.Prerelease, Draft = releaseData.Draft, - HtmlUrl = releaseData.HtmlUrl ?? string.Empty + HtmlUrl = releaseData.HtmlUrl ?? string.Empty, + PublishedAt = releaseData.PublishedAt }; } @@ -126,6 +127,9 @@ private sealed class GitHubReleaseResponse [JsonPropertyName("html_url")] public string? HtmlUrl { get; set; } + + [JsonPropertyName("published_at")] + public DateTimeOffset? PublishedAt { get; set; } } [JsonSerializable(typeof(GitHubReleaseResponse))] diff --git a/src/services/Elastic.Changelog/GitHub/IGitHubReleaseService.cs b/src/services/Elastic.Changelog/GitHub/IGitHubReleaseService.cs index 49281a04b..20f984ff2 100644 --- a/src/services/Elastic.Changelog/GitHub/IGitHubReleaseService.cs +++ b/src/services/Elastic.Changelog/GitHub/IGitHubReleaseService.cs @@ -38,6 +38,11 @@ public record GitHubReleaseInfo /// The URL to the release page on GitHub /// public string HtmlUrl { get; init; } = ""; + + /// + /// The date and time when this release was published on GitHub + /// + public DateTimeOffset? PublishedAt { get; init; } } /// diff --git a/src/services/Elastic.Changelog/GithubRelease/GitHubReleaseChangelogService.cs b/src/services/Elastic.Changelog/GithubRelease/GitHubReleaseChangelogService.cs index 97344fc15..ff4d87d7e 100644 --- a/src/services/Elastic.Changelog/GithubRelease/GitHubReleaseChangelogService.cs +++ b/src/services/Elastic.Changelog/GithubRelease/GitHubReleaseChangelogService.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; using Elastic.Changelog.Bundling; @@ -60,6 +61,12 @@ public record CreateChangelogsFromReleaseArguments /// public string? Description { get; init; } + /// + /// Optional explicit release date for the bundle in YYYY-MM-DD format. + /// When provided, overrides the GitHub release published_at date. + /// + public string? ReleaseDate { get; init; } + /// /// Whether to create a bundle file after creating individual changelog files. Defaults to true. /// Set to false when called from 'changelog add --release-version' to skip bundle creation. @@ -187,7 +194,7 @@ Cancel ctx // 8. Optionally create bundle file if changelogs were created if (input.CreateBundle && createdFiles.Count > 0) { - var bundlePath = await CreateBundleViaService(collector, outputDir, createdFiles, productInfo, owner, repo, input, ctx); + var bundlePath = await CreateBundleViaService(collector, outputDir, createdFiles, productInfo, owner, repo, input, release, ctx); if (bundlePath != null) _logger.LogInformation("Created bundle file: {BundlePath}", bundlePath); } @@ -313,6 +320,7 @@ private static string GenerateYaml(ChangelogEntry data) => string owner, string repo, CreateChangelogsFromReleaseArguments input, + GitHubReleaseInfo release, Cancel ctx) { // Build the bundles subfolder path (mirrors the previous CreateBundleFile convention) @@ -333,6 +341,13 @@ private static string GenerateYaml(ChangelogEntry data) => }) .ToArray(); + // Use explicit release date if provided, otherwise GitHub release published date, otherwise fall back to auto-population + var releaseDate = input.ReleaseDate; + if (string.IsNullOrEmpty(releaseDate) && release.PublishedAt.HasValue) + { + releaseDate = DateOnly.FromDateTime(release.PublishedAt.Value.DateTime).ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); + } + var bundleArgs = new BundleChangelogsArguments { Directory = outputDir, @@ -342,7 +357,8 @@ private static string GenerateYaml(ChangelogEntry data) => Repo = repo, Config = input.Config, OutputProducts = [productInfo], - Description = input.Description + Description = input.Description, + ReleaseDate = releaseDate }; var success = await _bundlingService.BundleChangelogs(collector, bundleArgs, ctx); diff --git a/src/services/Elastic.Changelog/Rendering/Asciidoc/ChangelogAsciidocRenderer.cs b/src/services/Elastic.Changelog/Rendering/Asciidoc/ChangelogAsciidocRenderer.cs index e23554c59..ebd3b771c 100644 --- a/src/services/Elastic.Changelog/Rendering/Asciidoc/ChangelogAsciidocRenderer.cs +++ b/src/services/Elastic.Changelog/Rendering/Asciidoc/ChangelogAsciidocRenderer.cs @@ -32,8 +32,8 @@ public async Task RenderAsciidoc(ChangelogRenderContext context, Cancel ctx) _ = sb.AppendLine(InvariantCulture, $"== {context.Title}"); _ = sb.AppendLine(); - // Add release date if present - if (context.BundleReleaseDate is { } releaseDate) + // Add release date if present and ShowReleaseDates is enabled + if (context.ShowReleaseDates && context.BundleReleaseDate is { } releaseDate) { _ = 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 4fcdfbe20..33730ff05 100644 --- a/src/services/Elastic.Changelog/Rendering/ChangelogRenderContext.cs +++ b/src/services/Elastic.Changelog/Rendering/ChangelogRenderContext.cs @@ -35,4 +35,10 @@ public record ChangelogRenderContext /// Only set when there's a single unique release date across all bundles (MVP approach). /// public DateOnly? BundleReleaseDate { get; init; } + + /// + /// Whether to show release dates in the rendered output. + /// Controls whether the BundleReleaseDate (if present) is displayed. + /// + public bool ShowReleaseDates { get; init; } } diff --git a/src/services/Elastic.Changelog/Rendering/ChangelogRenderingService.cs b/src/services/Elastic.Changelog/Rendering/ChangelogRenderingService.cs index 800d64b93..9f97ca669 100644 --- a/src/services/Elastic.Changelog/Rendering/ChangelogRenderingService.cs +++ b/src/services/Elastic.Changelog/Rendering/ChangelogRenderingService.cs @@ -173,8 +173,16 @@ Cancel ctx renderReleaseDate = bundleReleaseDates[0]; } + // Determine ShowReleaseDates setting from bundles (all must agree, default to false) + var bundleShowReleaseDates = validationResult.Bundles + .Select(b => b.Data?.ShowReleaseDates ?? false) + .Distinct() + .ToList(); + + var renderShowReleaseDates = bundleShowReleaseDates.Count == 1 && bundleShowReleaseDates[0]; + // Build render context - var context = BuildRenderContext(input, outputSetup, resolvedResult, combinedHideFeatures, config, renderDescription, renderReleaseDate); + var context = BuildRenderContext(input, outputSetup, resolvedResult, combinedHideFeatures, config, renderDescription, renderReleaseDate, renderShowReleaseDates); // Validate entry types if (!ValidateEntryTypes(collector, resolvedResult.Entries, config.Types)) @@ -288,7 +296,8 @@ private static ChangelogRenderContext BuildRenderContext( HashSet featureIdsToHide, ChangelogConfiguration? config, string? description = null, - DateOnly? releaseDate = null) + DateOnly? releaseDate = null, + bool showReleaseDates = false) { // Group entries by type var entriesByType = resolved.Entries @@ -331,7 +340,8 @@ private static ChangelogRenderContext BuildRenderContext( EntryToHideLinks = entryToHideLinks, Configuration = config, BundleDescription = description, - BundleReleaseDate = releaseDate + BundleReleaseDate = releaseDate, + ShowReleaseDates = showReleaseDates }; } diff --git a/src/services/Elastic.Changelog/Rendering/Markdown/IndexMarkdownRenderer.cs b/src/services/Elastic.Changelog/Rendering/Markdown/IndexMarkdownRenderer.cs index 9933eacee..02a8077d4 100644 --- a/src/services/Elastic.Changelog/Rendering/Markdown/IndexMarkdownRenderer.cs +++ b/src/services/Elastic.Changelog/Rendering/Markdown/IndexMarkdownRenderer.cs @@ -52,8 +52,8 @@ 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 (context.BundleReleaseDate is { } releaseDate) + // Add release date if present and ShowReleaseDates is enabled + if (context.ShowReleaseDates && context.BundleReleaseDate is { } releaseDate) { _ = sb.AppendLine(); _ = sb.AppendLine(InvariantCulture, $"_Released: {releaseDate.ToString("MMMM d, yyyy", InvariantCulture)}_"); diff --git a/src/services/Elastic.Changelog/Serialization/ChangelogConfigurationYaml.cs b/src/services/Elastic.Changelog/Serialization/ChangelogConfigurationYaml.cs index 372be436f..2c802fc0e 100644 --- a/src/services/Elastic.Changelog/Serialization/ChangelogConfigurationYaml.cs +++ b/src/services/Elastic.Changelog/Serialization/ChangelogConfigurationYaml.cs @@ -300,6 +300,11 @@ internal record BundleConfigurationYaml /// public YamlLenientList? LinkAllowRepos { get; set; } + /// + /// Whether to show release dates in rendered changelog output by default. + /// + public bool? ShowReleaseDates { get; set; } + /// /// Named bundle profiles. /// @@ -350,6 +355,12 @@ internal record BundleProfileYaml /// public YamlLenientList? HideFeatures { get; set; } + /// + /// Whether to show release dates in rendered changelog output for this profile. + /// Overrides bundle.show_release_dates when provided. + /// + public bool? ShowReleaseDates { get; set; } + /// /// Profile source type. When set to "github_release", the profile fetches /// PR references directly from a GitHub release and uses them as the bundle filter. diff --git a/src/tooling/docs-builder/Commands/ChangelogCommand.cs b/src/tooling/docs-builder/Commands/ChangelogCommand.cs index f071af8c6..c12895ce2 100644 --- a/src/tooling/docs-builder/Commands/ChangelogCommand.cs +++ b/src/tooling/docs-builder/Commands/ChangelogCommand.cs @@ -499,6 +499,8 @@ async static (s, collector, state, ctx) => await s.CreateChangelog(collector, st /// Optional: Directory containing changelog YAML files. Uses config bundle.directory or defaults to current directory /// Optional: Bundle description text with placeholder support. Supports {version}, {lifecycle}, {owner}, and {repo} placeholders. Overrides bundle.description from config. In option-based mode, placeholders require --output-products to be explicitly specified. /// Optional: Filter by feature IDs (comma-separated) or a path to a newline-delimited file containing feature IDs. Can be specified multiple times. Entries with matching feature-id values will be commented out when the bundle is rendered (by CLI render or {changelog} directive). + /// Optional: Skip auto-population of release date in the bundle. Mutually exclusive with --release-date. Not available in profile mode. + /// Optional: Explicit release date for the bundle in YYYY-MM-DD format. Overrides auto-population behavior. Mutually exclusive with --no-release-date. Not available in profile mode. /// Filter by products in format "product target lifecycle, ..." (for example, "cloud-serverless 2025-12-02 ga, cloud-serverless 2025-12-06 beta"). When specified, all three parts (product, target, lifecycle) are required but can be wildcards (*). Examples: "elasticsearch * *" matches all elasticsearch changelogs, "cloud-serverless 2025-12-02 *" matches cloud-serverless 2025-12-02 with any lifecycle, "* 9.3.* *" matches any product with target starting with "9.3.", "* * *" matches all changelogs (equivalent to --all). /// Filter by issue URLs (comma-separated), or a path to a newline-delimited file containing fully-qualified GitHub issue URLs. Can be specified multiple times. /// Optional: Output path for the bundled changelog. Can be either (1) a directory path, in which case 'changelog-bundle.yaml' is created in that directory, or (2) a file path ending in .yml or .yaml. Uses config bundle.output_directory or defaults to 'changelog-bundle.yaml' in the input directory @@ -522,6 +524,8 @@ public async Task Bundle( string? directory = null, string? description = null, string[]? hideFeatures = null, + bool noReleaseDate = false, + string? releaseDate = null, [ProductInfoParser] List? inputProducts = null, string? output = null, [ProductInfoParser] List? outputProducts = null, @@ -792,6 +796,35 @@ public async Task Bundle( return 0; } + // Validate release date flags + if (noReleaseDate && !string.IsNullOrWhiteSpace(releaseDate)) + { + collector.EmitError(string.Empty, "--no-release-date and --release-date are mutually exclusive."); + return 1; + } + + // Profile mode doesn't support release date CLI flags (use YAML configuration instead) + if (isProfileMode && (noReleaseDate || !string.IsNullOrWhiteSpace(releaseDate))) + { + var forbidden = new List(); + if (noReleaseDate) + forbidden.Add("--no-release-date"); + if (!string.IsNullOrWhiteSpace(releaseDate)) + forbidden.Add("--release-date"); + + collector.EmitError(string.Empty, + $"Profile mode does not support {string.Join(" and ", forbidden)}. " + + "Use bundle.show_release_dates configuration in changelog.yml instead."); + return 1; + } + + // Validate release date format if provided + if (!string.IsNullOrWhiteSpace(releaseDate) && !DateOnly.TryParseExact(releaseDate, "yyyy-MM-dd", out _)) + { + collector.EmitError(string.Empty, $"Invalid --release-date format '{releaseDate}'. Expected YYYY-MM-DD format."); + return 1; + } + // Determine resolve: CLI --no-resolve and --resolve override config. null = use config default. var shouldResolve = noResolve ? false : resolve; @@ -815,7 +848,8 @@ public async Task Bundle( Report = !isProfileMode ? report : null, Config = config, HideFeatures = allFeatureIdsForBundle.Count > 0 ? allFeatureIdsForBundle.ToArray() : null, - Description = description + Description = description, + ReleaseDate = noReleaseDate ? null : releaseDate }; serviceInvoker.AddCommand(service, input, @@ -1128,6 +1162,7 @@ async static (s, collector, state, ctx) => await s.RenderChangelogs(collector, s /// Optional: Path to the changelog.yml configuration file. Defaults to 'docs/changelog.yml' /// Optional: Bundle description text with placeholder support. Supports {version}, {lifecycle}, {owner}, and {repo} placeholders. Overrides bundle.description from config. /// Optional: Output directory for changelog files. Falls back to bundle.directory in changelog.yml when not specified. Defaults to './changelogs' + /// Optional: Explicit release date for the bundle in YYYY-MM-DD format. Overrides GitHub release published date. /// Optional: Remove square brackets and text within them from the beginning of PR titles (e.g., "[Inference API] Title" becomes "Title") /// Optional: Warn when the type inferred from release notes section headers doesn't match the type derived from PR labels. Defaults to true /// @@ -1138,6 +1173,7 @@ public async Task GitHubRelease( string? config = null, string? description = null, string? output = null, + string? releaseDate = null, bool stripTitlePrefix = false, bool warnOnTypeMismatch = true, Cancel ctx = default @@ -1154,6 +1190,13 @@ public async Task GitHubRelease( IGitHubPrService prService = new GitHubPrService(logFactory); var service = new GitHubReleaseChangelogService(logFactory, configurationContext, releaseService, prService); + // Validate release date format if provided + if (!string.IsNullOrWhiteSpace(releaseDate) && !DateOnly.TryParseExact(releaseDate, "yyyy-MM-dd", out _)) + { + collector.EmitError(string.Empty, $"Invalid --release-date format '{releaseDate}'. Expected YYYY-MM-DD format."); + return 1; + } + // Resolve stripTitlePrefix: CLI flag true → explicit true; otherwise null (use config default) var stripTitlePrefixResolved = stripTitlePrefix ? true : (bool?)null; @@ -1165,7 +1208,8 @@ public async Task GitHubRelease( Output = resolvedOutput, StripTitlePrefix = stripTitlePrefixResolved, WarnOnTypeMismatch = warnOnTypeMismatch, - Description = description + Description = description, + ReleaseDate = releaseDate }; serviceInvoker.AddCommand(service, input, From 4496bd2525b62f60b14756b06c0fcef29cb8cc98 Mon Sep 17 00:00:00 2001 From: lcawl Date: Fri, 10 Apr 2026 16:41:18 -0700 Subject: [PATCH 05/12] Fix Elastic.Markdown.Tests --- .../ReleaseNotes/Bundle.cs | 6 ++++++ .../ReleaseNotes/BundleLoader.cs | 12 +++++++++++- .../ReleaseNotes/ReleaseNotesSerialization.cs | 2 ++ .../Directives/ChangelogBasicTests.cs | 2 ++ 4 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/Elastic.Documentation.Configuration/ReleaseNotes/Bundle.cs b/src/Elastic.Documentation.Configuration/ReleaseNotes/Bundle.cs index 11e72a87f..94ed70989 100644 --- a/src/Elastic.Documentation.Configuration/ReleaseNotes/Bundle.cs +++ b/src/Elastic.Documentation.Configuration/ReleaseNotes/Bundle.cs @@ -24,6 +24,12 @@ public sealed record BundleDto [YamlMember(Alias = "release-date", ApplyNamingConventions = false)] public string? ReleaseDate { get; set; } /// + /// Whether to show release dates in rendered changelog output for this bundle. + /// When true, the ReleaseDate field (if present) will be displayed as "Released: date" text. + /// + [YamlMember(Alias = "show-release-dates", ApplyNamingConventions = false)] + public bool? ShowReleaseDates { 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 c3c1420fa..f081a75e2 100644 --- a/src/Elastic.Documentation.Configuration/ReleaseNotes/BundleLoader.cs +++ b/src/Elastic.Documentation.Configuration/ReleaseNotes/BundleLoader.cs @@ -242,7 +242,17 @@ private static LoadedBundle MergeBundleGroup(IGrouping gro _ => releaseDates[0] }; - var mergedData = first.Data with { Description = mergedDescription, ReleaseDate = mergedReleaseDate }; + var showReleaseDatesValues = bundlesList + .Select(b => b.Data?.ShowReleaseDates ?? false) + .Distinct() + .ToList(); + + // If all bundles agree on ShowReleaseDates, use that value; otherwise default to first bundle's value + var mergedShowReleaseDates = showReleaseDatesValues.Count == 1 ? showReleaseDatesValues[0] : first.Data?.ShowReleaseDates ?? false; + + var mergedData = first.Data != null + ? first.Data with { Description = mergedDescription, ReleaseDate = mergedReleaseDate, ShowReleaseDates = mergedShowReleaseDates } + : new Bundle { Description = mergedDescription, ReleaseDate = mergedReleaseDate, ShowReleaseDates = mergedShowReleaseDates }; return new LoadedBundle( first.Version, diff --git a/src/Elastic.Documentation.Configuration/ReleaseNotes/ReleaseNotesSerialization.cs b/src/Elastic.Documentation.Configuration/ReleaseNotes/ReleaseNotesSerialization.cs index 6f0690ec9..c9b794206 100644 --- a/src/Elastic.Documentation.Configuration/ReleaseNotes/ReleaseNotesSerialization.cs +++ b/src/Elastic.Documentation.Configuration/ReleaseNotes/ReleaseNotesSerialization.cs @@ -138,6 +138,7 @@ public static string SerializeBundle(Bundle bundle) Products = dto.Products?.Select(ToBundledProduct).ToList() ?? [], Description = dto.Description, ReleaseDate = ParseReleaseDate(dto.ReleaseDate), + ShowReleaseDates = dto.ShowReleaseDates ?? false, HideFeatures = dto.HideFeatures ?? [], Entries = dto.Entries?.Select(ToBundledEntry).ToList() ?? [] }; @@ -249,6 +250,7 @@ private static ChangelogEntryType ParseEntryType(string? value) Products = bundle.Products.Select(ToDto).ToList(), Description = bundle.Description, ReleaseDate = bundle.ReleaseDate?.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture), + ShowReleaseDates = bundle.ShowReleaseDates ? bundle.ShowReleaseDates : null, HideFeatures = bundle.HideFeatures.Count > 0 ? bundle.HideFeatures.ToList() : null, Entries = bundle.Entries.Select(ToDto).ToList() }; diff --git a/tests/Elastic.Markdown.Tests/Directives/ChangelogBasicTests.cs b/tests/Elastic.Markdown.Tests/Directives/ChangelogBasicTests.cs index 4f2b49466..ec7c6a770 100644 --- a/tests/Elastic.Markdown.Tests/Directives/ChangelogBasicTests.cs +++ b/tests/Elastic.Markdown.Tests/Directives/ChangelogBasicTests.cs @@ -628,6 +628,7 @@ public ChangelogReleaseDateTests(ITestOutputHelper output) : base(output, - product: apm-agent-dotnet target: 1.34.0 release-date: "2026-04-09" + show-release-dates: true entries: - title: Add tracing improvements type: feature @@ -695,6 +696,7 @@ public ChangelogReleaseDateWithDescriptionTests(ITestOutputHelper output) : base - product: apm-agent-dotnet target: 1.34.0 release-date: "2026-04-09" + show-release-dates: true description: | This release includes tracing improvements and bug fixes. entries: From d67277ee77990d3f2611671893a3bb89c31c680d Mon Sep 17 00:00:00 2001 From: Felipe Cotti Date: Wed, 15 Apr 2026 19:23:21 -0300 Subject: [PATCH 06/12] Fix --no-release-date and move ShowReleaseDate to the configuration --- .../ReleaseNotes/Bundle.cs | 6 -- .../ReleaseNotes/BundleLoader.cs | 12 +-- .../ReleaseNotes/ReleaseNotesSerialization.cs | 2 - .../ReleaseNotes/Bundle.cs | 6 -- .../Directives/Changelog/ChangelogBlock.cs | 81 +++++++++++++---- .../Changelog/ChangelogInlineRenderer.cs | 8 +- .../Bundling/ChangelogBundlingService.cs | 25 +++--- .../GitHubReleaseChangelogService.cs | 2 +- .../Rendering/ChangelogRenderingService.cs | 13 ++- .../docs-builder/Commands/ChangelogCommand.cs | 3 +- .../Directives/ChangelogBasicTests.cs | 88 +++++++++++-------- 11 files changed, 143 insertions(+), 103 deletions(-) diff --git a/src/Elastic.Documentation.Configuration/ReleaseNotes/Bundle.cs b/src/Elastic.Documentation.Configuration/ReleaseNotes/Bundle.cs index 94ed70989..11e72a87f 100644 --- a/src/Elastic.Documentation.Configuration/ReleaseNotes/Bundle.cs +++ b/src/Elastic.Documentation.Configuration/ReleaseNotes/Bundle.cs @@ -24,12 +24,6 @@ public sealed record BundleDto [YamlMember(Alias = "release-date", ApplyNamingConventions = false)] public string? ReleaseDate { get; set; } /// - /// Whether to show release dates in rendered changelog output for this bundle. - /// When true, the ReleaseDate field (if present) will be displayed as "Released: date" text. - /// - [YamlMember(Alias = "show-release-dates", ApplyNamingConventions = false)] - public bool? ShowReleaseDates { 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 f081a75e2..7956d5819 100644 --- a/src/Elastic.Documentation.Configuration/ReleaseNotes/BundleLoader.cs +++ b/src/Elastic.Documentation.Configuration/ReleaseNotes/BundleLoader.cs @@ -242,17 +242,9 @@ private static LoadedBundle MergeBundleGroup(IGrouping gro _ => releaseDates[0] }; - var showReleaseDatesValues = bundlesList - .Select(b => b.Data?.ShowReleaseDates ?? false) - .Distinct() - .ToList(); - - // If all bundles agree on ShowReleaseDates, use that value; otherwise default to first bundle's value - var mergedShowReleaseDates = showReleaseDatesValues.Count == 1 ? showReleaseDatesValues[0] : first.Data?.ShowReleaseDates ?? false; - var mergedData = first.Data != null - ? first.Data with { Description = mergedDescription, ReleaseDate = mergedReleaseDate, ShowReleaseDates = mergedShowReleaseDates } - : new Bundle { Description = mergedDescription, ReleaseDate = mergedReleaseDate, ShowReleaseDates = mergedShowReleaseDates }; + ? first.Data with { Description = mergedDescription, ReleaseDate = mergedReleaseDate } + : new Bundle { 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 c9b794206..6f0690ec9 100644 --- a/src/Elastic.Documentation.Configuration/ReleaseNotes/ReleaseNotesSerialization.cs +++ b/src/Elastic.Documentation.Configuration/ReleaseNotes/ReleaseNotesSerialization.cs @@ -138,7 +138,6 @@ public static string SerializeBundle(Bundle bundle) Products = dto.Products?.Select(ToBundledProduct).ToList() ?? [], Description = dto.Description, ReleaseDate = ParseReleaseDate(dto.ReleaseDate), - ShowReleaseDates = dto.ShowReleaseDates ?? false, HideFeatures = dto.HideFeatures ?? [], Entries = dto.Entries?.Select(ToBundledEntry).ToList() ?? [] }; @@ -250,7 +249,6 @@ private static ChangelogEntryType ParseEntryType(string? value) Products = bundle.Products.Select(ToDto).ToList(), Description = bundle.Description, ReleaseDate = bundle.ReleaseDate?.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture), - ShowReleaseDates = bundle.ShowReleaseDates ? bundle.ShowReleaseDates : null, 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 ccdd4848b..70c470eb7 100644 --- a/src/Elastic.Documentation/ReleaseNotes/Bundle.cs +++ b/src/Elastic.Documentation/ReleaseNotes/Bundle.cs @@ -27,12 +27,6 @@ public record Bundle /// public DateOnly? ReleaseDate { get; init; } - /// - /// Whether to show release dates in rendered changelog output for this bundle. - /// When true, the ReleaseDate field (if present) will be displayed as "Released: date" text. - /// - public bool ShowReleaseDates { 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/ChangelogBlock.cs b/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogBlock.cs index 4744e8e79..9d619efae 100644 --- a/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogBlock.cs +++ b/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogBlock.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.Text.RegularExpressions; using Elastic.Documentation; using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Assembler; @@ -70,7 +71,7 @@ public enum ChangelogTypeFilter /// /// Default bundles folder is changelog/bundles/ relative to the docset root. /// -public class ChangelogBlock(DirectiveBlockParser parser, ParserContext context) : DirectiveBlock(parser, context) +public partial class ChangelogBlock(DirectiveBlockParser parser, ParserContext context) : DirectiveBlock(parser, context) { /// /// Default folder for changelog bundles, relative to the documentation source directory. @@ -154,6 +155,11 @@ public class ChangelogBlock(DirectiveBlockParser parser, ParserContext context) /// public HashSet HideFeatures { get; private set; } = new(StringComparer.OrdinalIgnoreCase); + /// + /// Whether to show release dates in rendered output. Loaded from changelog.yml config. + /// + public bool ShowReleaseDates { get; private set; } + /// /// How to handle PR/issue links relative to private bundle repos (see :link-visibility: option). /// @@ -286,38 +292,75 @@ private void ExtractBundlesFolderPath() Found = true; } + [GeneratedRegex(@"^\s*show_release_dates\s*:\s*(?true|false)\s*$", RegexOptions.Multiline | RegexOptions.IgnoreCase)] + private static partial Regex ShowReleaseDatesPattern(); + /// - /// Reserved for future config loading (e.g., bundle.directory). The directive no longer applies rules.publish. - /// Emits a warning when an explicit :config: path is specified but the file is not found. + /// Loads changelog configuration settings (e.g. show_release_dates) from the config file. + /// Uses the explicit :config: path if specified, otherwise auto-discovers changelog.yml. /// private void LoadConfiguration() { - if (string.IsNullOrWhiteSpace(ConfigPath)) + var configFilePath = ResolveConfigPath(); + if (configFilePath == null) return; - var trimmedPath = ConfigPath.TrimStart('/'); - if (Path.IsPathRooted(trimmedPath)) + try { - this.EmitError("Changelog config path must not be an absolute path."); - return; + var yaml = Build.ReadFileSystem.File.ReadAllText(configFilePath); + var match = ShowReleaseDatesPattern().Match(yaml); + if (match.Success) + ShowReleaseDates = string.Equals(match.Groups["value"].Value, "true", StringComparison.OrdinalIgnoreCase); } - - var explicitPath = Path.GetFullPath(Build.DocumentationSourceDirectory.ResolvePathFrom(ConfigPath)); - var file = Build.ReadFileSystem.FileInfo.New(explicitPath); - if (!file.IsSubPathOf(Build.DocumentationSourceDirectory)) + catch { - this.EmitError("Changelog config path must resolve within the documentation source directory."); - return; + // Config parsing failure is non-fatal for the directive } + } - if (SymlinkValidator.ValidateFileAccess(file, Build.DocumentationSourceDirectory) is { } accessError) + private string? ResolveConfigPath() + { + if (!string.IsNullOrWhiteSpace(ConfigPath)) { - this.EmitError(accessError); - return; + var trimmedPath = ConfigPath.TrimStart('/'); + if (Path.IsPathRooted(trimmedPath)) + { + this.EmitError("Changelog config path must not be an absolute path."); + return null; + } + + var explicitPath = Path.GetFullPath(Build.DocumentationSourceDirectory.ResolvePathFrom(ConfigPath)); + var file = Build.ReadFileSystem.FileInfo.New(explicitPath); + if (!file.IsSubPathOf(Build.DocumentationSourceDirectory)) + { + this.EmitError("Changelog config path must resolve within the documentation source directory."); + return null; + } + + if (SymlinkValidator.ValidateFileAccess(file, Build.DocumentationSourceDirectory) is { } accessError) + { + this.EmitError(accessError); + return null; + } + + if (!Build.ReadFileSystem.File.Exists(explicitPath)) + { + this.EmitWarning($"Specified changelog config path '{ConfigPath}' not found."); + return null; + } + + return explicitPath; } - if (!Build.ReadFileSystem.File.Exists(explicitPath)) - this.EmitWarning($"Specified changelog config path '{ConfigPath}' not found."); + // Auto-discover changelog.yml relative to doc source directory + var candidates = new[] + { + Path.GetFullPath(Build.DocumentationSourceDirectory.ResolvePathFrom("changelog.yml")), + Path.GetFullPath(Build.DocumentationSourceDirectory.ResolvePathFrom("../changelog.yml")), + Path.GetFullPath(Build.DocumentationSourceDirectory.ResolvePathFrom("../docs/changelog.yml")) + }; + + return candidates.FirstOrDefault(Build.ReadFileSystem.File.Exists); } /// diff --git a/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogInlineRenderer.cs b/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogInlineRenderer.cs index 86f094533..46c750ea8 100644 --- a/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogInlineRenderer.cs +++ b/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogInlineRenderer.cs @@ -37,7 +37,8 @@ public static class ChangelogInlineRenderer block.PrivateRepositories, block.HideFeatures, typeFilter, - block.LinkVisibility); + block.LinkVisibility, + block.ShowReleaseDates); _ = sb.Append(bundleMarkdown); isFirst = false; @@ -53,7 +54,8 @@ private static string RenderSingleBundle( HashSet privateRepositories, HashSet hideFeatures, ChangelogTypeFilter typeFilter, - ChangelogLinkVisibility linkVisibility) + ChangelogLinkVisibility linkVisibility, + bool showReleaseDates) { var titleSlug = ChangelogTextUtilities.TitleToSlug(bundle.Version); @@ -79,7 +81,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, bundle.Data?.ReleaseDate, bundle.Data?.ShowReleaseDates ?? false); + return GenerateMarkdown(displayVersion, titleSlug, bundle.Repo, bundle.Owner, entriesByType, subsections, hideLinks, typeFilter, publishBlocker, bundle.Data?.Description, bundle.Data?.ReleaseDate, showReleaseDates); } /// diff --git a/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs b/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs index 42071ab89..c47da4fd5 100644 --- a/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs +++ b/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs @@ -98,7 +98,15 @@ public record BundleChangelogsArguments public string? ReleaseDate { get; init; } /// - /// Whether to show release dates in rendered output. When null, inherits from config/profile. + /// When true, skips auto-population of release date (respects --no-release-date). + /// Existing dates in bundle YAML files are still preserved. + /// + public bool SuppressReleaseDate { get; init; } + + /// + /// Whether to show release dates in rendered output. + /// Resolved from profile (if applicable) > config > default (false). + /// Not written to bundle YAML; purely an in-memory rendering concern. /// public bool? ShowReleaseDates { get; init; } @@ -365,7 +373,7 @@ public async Task BundleChangelogs(IDiagnosticsCollector collector, Bundle } } - // Apply release date auto-population and ShowReleaseDates setting + // Apply release date: CLI override → existing bundle date → auto-populate (unless suppressed) var finalReleaseDate = bundleData.ReleaseDate; // Preserve existing date if present if (!string.IsNullOrEmpty(input.ReleaseDate)) { @@ -380,20 +388,13 @@ public async Task BundleChangelogs(IDiagnosticsCollector collector, Bundle return false; } } - else if (finalReleaseDate == null) + else if (finalReleaseDate == null && !input.SuppressReleaseDate) { // Auto-populate with today's date (UTC) if no existing date finalReleaseDate = DateOnly.FromDateTime(DateTime.UtcNow); } - // Apply ShowReleaseDates setting (input takes precedence over config) - var showReleaseDates = input.ShowReleaseDates ?? config?.Bundle?.ShowReleaseDates ?? false; - - bundleData = bundleData with - { - ReleaseDate = finalReleaseDate, - ShowReleaseDates = showReleaseDates - }; + bundleData = bundleData with { ReleaseDate = finalReleaseDate }; // Write bundle file await WriteBundleFileAsync(bundleData, outputPath, ctx); @@ -479,7 +480,7 @@ public async Task BundleChangelogs(IDiagnosticsCollector collector, Bundle owner = profile.Owner ?? config.Bundle.Owner; mergedHideFeatures = profile.HideFeatures?.Count > 0 ? [.. profile.HideFeatures] : null; - // Profile-level ShowReleaseDates takes precedence; fall back to bundle-level default + // Profile-level ShowReleaseDates takes precedence over bundle-level default profileShowReleaseDates = profile.ShowReleaseDates ?? config.Bundle.ShowReleaseDates; // Handle profile-specific description with placeholder substitution diff --git a/src/services/Elastic.Changelog/GithubRelease/GitHubReleaseChangelogService.cs b/src/services/Elastic.Changelog/GithubRelease/GitHubReleaseChangelogService.cs index ff4d87d7e..958c74bcf 100644 --- a/src/services/Elastic.Changelog/GithubRelease/GitHubReleaseChangelogService.cs +++ b/src/services/Elastic.Changelog/GithubRelease/GitHubReleaseChangelogService.cs @@ -345,7 +345,7 @@ private static string GenerateYaml(ChangelogEntry data) => var releaseDate = input.ReleaseDate; if (string.IsNullOrEmpty(releaseDate) && release.PublishedAt.HasValue) { - releaseDate = DateOnly.FromDateTime(release.PublishedAt.Value.DateTime).ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); + releaseDate = DateOnly.FromDateTime(release.PublishedAt.Value.UtcDateTime).ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); } var bundleArgs = new BundleChangelogsArguments diff --git a/src/services/Elastic.Changelog/Rendering/ChangelogRenderingService.cs b/src/services/Elastic.Changelog/Rendering/ChangelogRenderingService.cs index 9f97ca669..daf722821 100644 --- a/src/services/Elastic.Changelog/Rendering/ChangelogRenderingService.cs +++ b/src/services/Elastic.Changelog/Rendering/ChangelogRenderingService.cs @@ -31,6 +31,11 @@ public record RenderChangelogsArguments public string[]? HideFeatures { get; init; } public string? Config { get; init; } public ChangelogFileType FileType { get; init; } = ChangelogFileType.Markdown; + + /// + /// Optional override for showing release dates. When null, falls back to config's bundle.show_release_dates. + /// + public bool? ShowReleaseDates { get; init; } } /// @@ -173,13 +178,7 @@ Cancel ctx renderReleaseDate = bundleReleaseDates[0]; } - // Determine ShowReleaseDates setting from bundles (all must agree, default to false) - var bundleShowReleaseDates = validationResult.Bundles - .Select(b => b.Data?.ShowReleaseDates ?? false) - .Distinct() - .ToList(); - - var renderShowReleaseDates = bundleShowReleaseDates.Count == 1 && bundleShowReleaseDates[0]; + var renderShowReleaseDates = input.ShowReleaseDates ?? config.Bundle?.ShowReleaseDates ?? false; // Build render context var context = BuildRenderContext(input, outputSetup, resolvedResult, combinedHideFeatures, config, renderDescription, renderReleaseDate, renderShowReleaseDates); diff --git a/src/tooling/docs-builder/Commands/ChangelogCommand.cs b/src/tooling/docs-builder/Commands/ChangelogCommand.cs index c12895ce2..7cbf0faa3 100644 --- a/src/tooling/docs-builder/Commands/ChangelogCommand.cs +++ b/src/tooling/docs-builder/Commands/ChangelogCommand.cs @@ -849,7 +849,8 @@ public async Task Bundle( Config = config, HideFeatures = allFeatureIdsForBundle.Count > 0 ? allFeatureIdsForBundle.ToArray() : null, Description = description, - ReleaseDate = noReleaseDate ? null : releaseDate + ReleaseDate = releaseDate, + SuppressReleaseDate = noReleaseDate }; serviceInvoker.AddCommand(service, input, diff --git a/tests/Elastic.Markdown.Tests/Directives/ChangelogBasicTests.cs b/tests/Elastic.Markdown.Tests/Directives/ChangelogBasicTests.cs index ec7c6a770..478b3b91d 100644 --- a/tests/Elastic.Markdown.Tests/Directives/ChangelogBasicTests.cs +++ b/tests/Elastic.Markdown.Tests/Directives/ChangelogBasicTests.cs @@ -621,23 +621,31 @@ public ChangelogReleaseDateTests(ITestOutputHelper output) : base(output, """ :::{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" - show-release-dates: true - entries: - - title: Add tracing improvements - type: feature - products: - - product: apm-agent-dotnet - target: 1.34.0 - prs: - - "500" - """)); + """) + { + FileSystem.AddFile("docs/changelog.yml", new MockFileData( + // language=yaml + """ + bundle: + show_release_dates: true + """)); + 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" + entries: + - title: Add tracing improvements + type: feature + products: + - product: apm-agent-dotnet + target: 1.34.0 + prs: + - "500" + """)); + } [Fact] public void RendersReleaseDate() => @@ -689,25 +697,33 @@ public ChangelogReleaseDateWithDescriptionTests(ITestOutputHelper output) : base """ :::{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" - show-release-dates: true - 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" - """)); + """) + { + FileSystem.AddFile("docs/changelog.yml", new MockFileData( + // language=yaml + """ + bundle: + show_release_dates: true + """)); + 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() => From cd42c3733037694065cfeafbf65e1ba1917cc50d Mon Sep 17 00:00:00 2001 From: Felipe Cotti Date: Wed, 15 Apr 2026 19:37:33 -0300 Subject: [PATCH 07/12] FIx serialization --- .../Directives/Changelog/ChangelogBlock.cs | 22 +++++++++------- .../Changelog/ChangelogConfigYaml.cs | 26 +++++++++++++++++++ .../Myst/YamlSerialization.cs | 3 +++ 3 files changed, 42 insertions(+), 9 deletions(-) create mode 100644 src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogConfigYaml.cs diff --git a/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogBlock.cs b/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogBlock.cs index 9d619efae..26bdb00df 100644 --- a/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogBlock.cs +++ b/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogBlock.cs @@ -2,7 +2,6 @@ // 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.Text.RegularExpressions; using Elastic.Documentation; using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Assembler; @@ -11,6 +10,8 @@ using Elastic.Documentation.ReleaseNotes; using Elastic.Markdown.Diagnostics; using Elastic.Markdown.Helpers; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.NamingConventions; namespace Elastic.Markdown.Myst.Directives.Changelog; @@ -71,7 +72,7 @@ public enum ChangelogTypeFilter /// /// Default bundles folder is changelog/bundles/ relative to the docset root. /// -public partial class ChangelogBlock(DirectiveBlockParser parser, ParserContext context) : DirectiveBlock(parser, context) +public class ChangelogBlock(DirectiveBlockParser parser, ParserContext context) : DirectiveBlock(parser, context) { /// /// Default folder for changelog bundles, relative to the documentation source directory. @@ -292,8 +293,11 @@ private void ExtractBundlesFolderPath() Found = true; } - [GeneratedRegex(@"^\s*show_release_dates\s*:\s*(?true|false)\s*$", RegexOptions.Multiline | RegexOptions.IgnoreCase)] - private static partial Regex ShowReleaseDatesPattern(); + private static readonly IDeserializer ConfigDeserializer = + new StaticDeserializerBuilder(new DocsBuilderYamlStaticContext()) + .WithNamingConvention(UnderscoredNamingConvention.Instance) + .IgnoreUnmatchedProperties() + .Build(); /// /// Loads changelog configuration settings (e.g. show_release_dates) from the config file. @@ -308,13 +312,13 @@ private void LoadConfiguration() try { var yaml = Build.ReadFileSystem.File.ReadAllText(configFilePath); - var match = ShowReleaseDatesPattern().Match(yaml); - if (match.Success) - ShowReleaseDates = string.Equals(match.Groups["value"].Value, "true", StringComparison.OrdinalIgnoreCase); + var config = ConfigDeserializer.Deserialize(yaml); + if (config.Bundle?.ShowReleaseDates is true) + ShowReleaseDates = true; } - catch + catch (Exception) { - // Config parsing failure is non-fatal for the directive + this.EmitWarning("Failed to parse changelog configuration; show_release_dates will use the default (false)."); } } diff --git a/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogConfigYaml.cs b/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogConfigYaml.cs new file mode 100644 index 000000000..24ed60bb7 --- /dev/null +++ b/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogConfigYaml.cs @@ -0,0 +1,26 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// 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 YamlDotNet.Serialization; + +namespace Elastic.Markdown.Myst.Directives.Changelog; + +/// +/// Minimal YAML DTO for reading changelog.yml settings needed by the {changelog} directive. +/// Only the fields relevant to directive rendering are included; everything else is ignored. +/// +[YamlSerializable] +internal sealed record ChangelogDirectiveConfigYaml +{ + public ChangelogDirectiveBundleConfigYaml? Bundle { get; set; } +} + +/// +/// Minimal bundle section from changelog.yml, containing only directive-relevant settings. +/// +[YamlSerializable] +internal sealed record ChangelogDirectiveBundleConfigYaml +{ + public bool? ShowReleaseDates { get; set; } +} diff --git a/src/Elastic.Markdown/Myst/YamlSerialization.cs b/src/Elastic.Markdown/Myst/YamlSerialization.cs index 6d84d6730..4f8502e75 100644 --- a/src/Elastic.Markdown/Myst/YamlSerialization.cs +++ b/src/Elastic.Markdown/Myst/YamlSerialization.cs @@ -4,6 +4,7 @@ using Elastic.Documentation.AppliesTo; using Elastic.Documentation.Configuration.Products; +using Elastic.Markdown.Myst.Directives.Changelog; using Elastic.Markdown.Myst.Directives.Contributors; using Elastic.Markdown.Myst.Directives.Settings; using Elastic.Markdown.Myst.FrontMatter; @@ -39,4 +40,6 @@ public static T Deserialize(string yaml, ProductsConfiguration products) [YamlSerializable(typeof(SettingMutability))] [YamlSerializable(typeof(ApplicableTo))] [YamlSerializable(typeof(ContributorEntry))] +[YamlSerializable(typeof(ChangelogDirectiveConfigYaml))] +[YamlSerializable(typeof(ChangelogDirectiveBundleConfigYaml))] public partial class DocsBuilderYamlStaticContext; From 8631161c026c8cf094a4b0784cbb7a3cf88372af Mon Sep 17 00:00:00 2001 From: Felipe Cotti Date: Wed, 15 Apr 2026 19:42:09 -0300 Subject: [PATCH 08/12] Return to skipping in this case --- .../Myst/Directives/Changelog/ChangelogBlock.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogBlock.cs b/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogBlock.cs index 26bdb00df..290d74e85 100644 --- a/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogBlock.cs +++ b/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogBlock.cs @@ -316,9 +316,10 @@ private void LoadConfiguration() if (config.Bundle?.ShowReleaseDates is true) ShowReleaseDates = true; } - catch (Exception) + catch { - this.EmitWarning("Failed to parse changelog configuration; show_release_dates will use the default (false)."); + // Best-effort: if the config is malformed the CLI pipeline will report + // proper errors; the directive just falls back to ShowReleaseDates = false. } } From bd50396479afd4b7fd3ee425295733df58de5e28 Mon Sep 17 00:00:00 2001 From: Felipe Cotti Date: Wed, 15 Apr 2026 19:46:06 -0300 Subject: [PATCH 09/12] Improve config acquisition on changelogblock --- .../Myst/Directives/Changelog/ChangelogBlock.cs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogBlock.cs b/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogBlock.cs index 290d74e85..a8f7ca4f2 100644 --- a/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogBlock.cs +++ b/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogBlock.cs @@ -365,7 +365,17 @@ private void LoadConfiguration() Path.GetFullPath(Build.DocumentationSourceDirectory.ResolvePathFrom("../docs/changelog.yml")) }; - return candidates.FirstOrDefault(Build.ReadFileSystem.File.Exists); + return candidates.FirstOrDefault(c => + { + try + { + return Build.ReadFileSystem.File.Exists(c); + } + catch + { + return false; + } + }); } /// From 72fbc96733159b6d394a4a0d7144e867139054d3 Mon Sep 17 00:00:00 2001 From: Felipe Cotti Date: Wed, 15 Apr 2026 19:52:50 -0300 Subject: [PATCH 10/12] Fix discrepancies between config acquisition methods --- .../Directives/Changelog/ChangelogBlock.cs | 77 ++++++++++++------- 1 file changed, 50 insertions(+), 27 deletions(-) diff --git a/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogBlock.cs b/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogBlock.cs index a8f7ca4f2..be5e3dbff 100644 --- a/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogBlock.cs +++ b/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogBlock.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.IO.Abstractions; using Elastic.Documentation; using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Assembler; @@ -323,6 +324,15 @@ private void LoadConfiguration() } } + /// + /// The trust boundary for changelog config file resolution: checkout (git) root + /// when available, otherwise the documentation source directory. + /// Both explicit :config: paths and auto-discovered candidates are validated + /// against this same root. + /// + private IDirectoryInfo ConfigTrustRoot => + Build.DocumentationCheckoutDirectory ?? Build.DocumentationSourceDirectory; + private string? ResolveConfigPath() { if (!string.IsNullOrWhiteSpace(ConfigPath)) @@ -335,47 +345,60 @@ private void LoadConfiguration() } var explicitPath = Path.GetFullPath(Build.DocumentationSourceDirectory.ResolvePathFrom(ConfigPath)); - var file = Build.ReadFileSystem.FileInfo.New(explicitPath); - if (!file.IsSubPathOf(Build.DocumentationSourceDirectory)) + return ValidateConfigCandidate(explicitPath, emitDiagnostics: true); + } + + // Auto-discover: try .yml and .yaml in each candidate location. + string[] relativePaths = + [ + "changelog.yml", "changelog.yaml", + "../changelog.yml", "../changelog.yaml" + ]; + + return relativePaths + .Select(rel => Path.GetFullPath(Build.DocumentationSourceDirectory.ResolvePathFrom(rel))) + .Select(abs => ValidateConfigCandidate(abs, emitDiagnostics: false)) + .FirstOrDefault(p => p != null); + } + + /// + /// Validates a config file candidate against the shared trust rules: + /// must be within , must not be/traverse symlinks, + /// and must exist on the (scoped) filesystem. + /// + private string? ValidateConfigCandidate(string fullPath, bool emitDiagnostics) + { + try + { + var file = Build.ReadFileSystem.FileInfo.New(fullPath); + + if (!file.IsSubPathOf(ConfigTrustRoot)) { - this.EmitError("Changelog config path must resolve within the documentation source directory."); + if (emitDiagnostics) + this.EmitError("Changelog config path must resolve within the documentation directory."); return null; } - if (SymlinkValidator.ValidateFileAccess(file, Build.DocumentationSourceDirectory) is { } accessError) + if (SymlinkValidator.ValidateFileAccess(file, ConfigTrustRoot) is { } accessError) { - this.EmitError(accessError); + if (emitDiagnostics) + this.EmitError(accessError); return null; } - if (!Build.ReadFileSystem.File.Exists(explicitPath)) + if (!Build.ReadFileSystem.File.Exists(fullPath)) { - this.EmitWarning($"Specified changelog config path '{ConfigPath}' not found."); + if (emitDiagnostics) + this.EmitWarning($"Specified changelog config path '{ConfigPath}' not found."); return null; } - return explicitPath; + return fullPath; } - - // Auto-discover changelog.yml relative to doc source directory - var candidates = new[] - { - Path.GetFullPath(Build.DocumentationSourceDirectory.ResolvePathFrom("changelog.yml")), - Path.GetFullPath(Build.DocumentationSourceDirectory.ResolvePathFrom("../changelog.yml")), - Path.GetFullPath(Build.DocumentationSourceDirectory.ResolvePathFrom("../docs/changelog.yml")) - }; - - return candidates.FirstOrDefault(c => + catch { - try - { - return Build.ReadFileSystem.File.Exists(c); - } - catch - { - return false; - } - }); + return null; + } } /// From c1dfc576c565511cb5ce3da0514a6d70565e211c Mon Sep 17 00:00:00 2001 From: Felipe Cotti Date: Wed, 15 Apr 2026 20:05:13 -0300 Subject: [PATCH 11/12] Adjust docs --- config/changelog.example.yml | 4 ++-- docs/syntax/changelog.md | 15 +++++++++------ 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/config/changelog.example.yml b/config/changelog.example.yml index e49f75552..3d4d5a754 100644 --- a/config/changelog.example.yml +++ b/config/changelog.example.yml @@ -259,11 +259,11 @@ bundle: # # description: | # # Elasticsearch {version} includes: # # - Performance improvements - # # Optional: profile-specific release date display setting (overrides bundle.show_release_dates) - # # show_release_dates: false # # - Bug fixes and stability enhancements # # # # Download the release binaries: https://github.com/{owner}/{repo}/releases/tag/v{version} + # # Optional: profile-specific release date display setting (overrides bundle.show_release_dates) + # # show_release_dates: false # Example: GitHub release profile (fetches PR list directly from a GitHub release) # Use when you want to bundle or remove changelogs based on a published GitHub release. # elasticsearch-gh-release: diff --git a/docs/syntax/changelog.md b/docs/syntax/changelog.md index be0bb25c8..f6bbc5a29 100644 --- a/docs/syntax/changelog.md +++ b/docs/syntax/changelog.md @@ -25,7 +25,7 @@ The directive supports the following options: | `:type: value` | Filter entries by type | Excludes separated types | | `:subsections:` | Group entries by area/component | false | | `:link-visibility: value` | Visibility of pull request (PR) and issue links | `auto` | -| `:config: path` | Path to `changelog.yml` configuration (reserved for future use) | auto-discover | +| `:config: path` | Path to `changelog.yml` configuration | auto-discover | ### Example with options @@ -122,11 +122,14 @@ If a changelog has multiple area values, only the first one is used. #### `:config:` -Explicit path to a `changelog.yml` configuration file. If not specified, the directive auto-discovers from: -1. `changelog.yml` in the docset root -2. `docs/changelog.yml` relative to docset root +Explicit path to a `changelog.yml` or `changelog.yaml` configuration file, relative to the documentation source directory. If not specified, the directive auto-discovers from these locations (first match wins): -Reserved for future configuration use. The directive does not currently load or apply configuration from this file. +1. `changelog.yml` or `changelog.yaml` in the documentation source directory +2. `changelog.yml` or `changelog.yaml` in the parent directory (typically the repository root) + +Both explicit and auto-discovered paths must resolve within the repository checkout directory and must not traverse symlinks. + +The directive currently reads the `bundle.show_release_dates` setting from this file to control whether release dates are displayed in the rendered output. ## Filtering entries with bundle rules @@ -251,7 +254,7 @@ Download the release binaries: https://github.com/elastic/elasticsearch/releases 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. -The `show_release_dates` setting in `changelog.yml` (boolean, defaults to `false`) controls whether release dates appear in the rendered output. When set to `false`, the `release-date` field is ignored during rendering. +The `bundle.show_release_dates` setting in `changelog.yml` (boolean, defaults to `false`) controls whether release dates appear in the rendered output. When set to `false`, the `release-date` field is preserved in the bundle YAML but ignored during rendering. The `{changelog}` directive auto-discovers this setting from `changelog.yml` (see [`:config:`](#config) for the discovery order). Per-profile overrides (`bundle.profiles..show_release_dates`) are supported by the CLI but not by the directive, which reads only the bundle-level default. 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. From 3e9fd3020a282d8be96a3e8d4d7f6625911b99db Mon Sep 17 00:00:00 2001 From: Felipe Cotti Date: Wed, 15 Apr 2026 20:10:13 -0300 Subject: [PATCH 12/12] Small adjust to docset root inference --- .../Myst/Directives/Changelog/ChangelogBlock.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogBlock.cs b/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogBlock.cs index be5e3dbff..0f54c3420 100644 --- a/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogBlock.cs +++ b/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogBlock.cs @@ -337,14 +337,15 @@ private void LoadConfiguration() { if (!string.IsNullOrWhiteSpace(ConfigPath)) { - var trimmedPath = ConfigPath.TrimStart('/'); + // A leading '/' or '\' is treated as relative to docset root + var trimmedPath = ConfigPath.TrimStart('/', '\\'); if (Path.IsPathRooted(trimmedPath)) { this.EmitError("Changelog config path must not be an absolute path."); return null; } - var explicitPath = Path.GetFullPath(Build.DocumentationSourceDirectory.ResolvePathFrom(ConfigPath)); + var explicitPath = Path.GetFullPath(Build.DocumentationSourceDirectory.ResolvePathFrom(trimmedPath)); return ValidateConfigCandidate(explicitPath, emitDiagnostics: true); }