diff --git a/docs/cli-schema.json b/docs/cli-schema.json
index 1e089f1443..9d6d52fb9e 100644
--- a/docs/cli-schema.json
+++ b/docs/cli-schema.json
@@ -4500,7 +4500,7 @@
"shortName": null,
"type": "string",
"required": false,
- "summary": "Optional: Output file type. Valid values: \u0022markdown\u0022 or \u0022asciidoc\u0022. Defaults to \u0022markdown\u0022",
+ "summary": "Optional: Output file type. Valid values: \u0022markdown\u0022, \u0022asciidoc\u0022, or \u0022gfm\u0022. Defaults to \u0022markdown\u0022",
"defaultValue": "markdown"
},
{
@@ -4539,6 +4539,15 @@
"summary": "Optional: Render separated types (breaking changes, deprecations, known issues, highlights) as MyST dropdowns. When false (default), renders as flattened bulleted lists. Defaults to false",
"defaultValue": "false"
},
+ {
+ "role": "flag",
+ "name": "no-descriptions",
+ "shortName": null,
+ "type": "boolean",
+ "required": false,
+ "summary": "Optional: Hide changelog record descriptions from output. When enabled, entry titles, PR/issue links, Impact and Action sections remain visible. Bundle-level descriptions are unaffected. Defaults to false",
+ "defaultValue": "false"
+ },
{
"role": "flag",
"name": "title",
diff --git a/docs/cli/changelog/cmd-render.md b/docs/cli/changelog/cmd-render.md
index ffd314a2b0..cd5b84e6c3 100644
--- a/docs/cli/changelog/cmd-render.md
+++ b/docs/cli/changelog/cmd-render.md
@@ -52,6 +52,33 @@ AsciiDoc output ignores the `--dropdowns` flag and always uses a standardized fo
- Strong text formatting uses idiomatic single asterisk syntax (`*Impact:*`, `*Action:*`) following AsciiDoc best practices
- All content blocks are properly attached to their parent list items for correct rendering.
+### GFM format
+
+When `--file-type gfm` is specified, the command generates a single GitHub Flavored Markdown file optimized for GitHub releases:
+
+- `changelog.md` - Contains all sections in a single file with clean headings
+- Clean section headings without anchor links (for example, `### Features and enhancements`)
+- Simplified structure focused on readability
+- Suitable for copy/pasting into GitHub releases
+
+The GFM output includes the following sections in order when entries are present:
+
+- Highlights (only included when at least one entry has `highlight: true`)
+- Features and enhancements
+- Breaking changes
+- Deprecations
+- Bug fixes (includes security updates)
+- Known issues
+- Documentation
+- Regressions
+- Other changes
+
+AsciiDoc output ignores the `--dropdowns` flag and always uses a standardized format with the following characteristics:
+
+- Multi-block entries (containing description, Impact, and Action sections) use proper list continuation markers (`+`) to maintain list structure
+- Strong text formatting uses idiomatic single asterisk syntax (`*Impact:*`, `*Action:*`) following AsciiDoc best practices
+- All content blocks are properly attached to their parent list items for correct rendering
+
### Multiple PR and issue links
Changelog entries can reference multiple pull requests and issues via the `prs` and `issues` array fields. All links are rendered inline:
@@ -66,7 +93,7 @@ Changelog entries can reference multiple pull requests and issues via the `prs`
# Render a single bundle
docs-builder changelog render \
--input "./docs/changelog/bundles/9.3.0.yaml" \
- --output ./release-notes
+ --output ./release-notes \
# Render with explicit changelog dir and repo
docs-builder changelog render \
@@ -94,4 +121,10 @@ docs-builder changelog render \
--input "./docs/changelog/bundles/9.3.0.yaml" \
--output ./release-notes \
--dropdowns
+
+# Render as GitHub Flavored Markdown
+docs-builder changelog render \
+ --input "./docs/changelog/bundles/9.3.0.yaml" \
+ --output ./release-notes \
+ --file-type gfm
```
diff --git a/src/services/Elastic.Changelog/Rendering/Asciidoc/AsciidocRendererBase.cs b/src/services/Elastic.Changelog/Rendering/Asciidoc/AsciidocRendererBase.cs
index d9aa5fcb6c..85d8b8809b 100644
--- a/src/services/Elastic.Changelog/Rendering/Asciidoc/AsciidocRendererBase.cs
+++ b/src/services/Elastic.Changelog/Rendering/Asciidoc/AsciidocRendererBase.cs
@@ -63,9 +63,9 @@ private static void RenderEntryTitleAndLinks(StringBuilder sb, ChangelogEntry en
///
/// Renders an entry's description with optional comment handling and list continuation
///
- private static void RenderEntryDescription(StringBuilder sb, ChangelogEntry entry, bool shouldHide, bool needsContinuation = true)
+ private static void RenderEntryDescription(StringBuilder sb, ChangelogEntry entry, ChangelogRenderContext context, bool shouldHide, bool needsContinuation = true)
{
- if (string.IsNullOrWhiteSpace(entry.Description))
+ if (context.HideDescriptions || string.IsNullOrWhiteSpace(entry.Description))
return;
_ = sb.AppendLine();
@@ -113,7 +113,7 @@ protected void RenderBasicEntry(StringBuilder sb, ChangelogEntry entry, Changelo
{
var (entryRepo, _, hideLinks, shouldHide) = ChangelogRenderUtilities.GetEntryContext(entry, context);
RenderEntryTitleAndLinks(sb, entry, entryRepo, hideLinks, shouldHide);
- RenderEntryDescription(sb, entry, shouldHide, needsContinuation: !string.IsNullOrWhiteSpace(entry.Description));
+ RenderEntryDescription(sb, entry, context, shouldHide, needsContinuation: !string.IsNullOrWhiteSpace(entry.Description));
_ = sb.AppendLine();
}
@@ -127,7 +127,7 @@ protected void RenderEntryWithImpactAction(StringBuilder sb, ChangelogEntry entr
// Description needs continuation when it exists
var hasDescription = !string.IsNullOrWhiteSpace(entry.Description);
- RenderEntryDescription(sb, entry, shouldHide, needsContinuation: hasDescription);
+ RenderEntryDescription(sb, entry, context, shouldHide, needsContinuation: hasDescription);
RenderImpactAndAction(sb, entry);
_ = sb.AppendLine();
diff --git a/src/services/Elastic.Changelog/Rendering/ChangelogRenderContext.cs b/src/services/Elastic.Changelog/Rendering/ChangelogRenderContext.cs
index 19a949aa76..dac9524d55 100644
--- a/src/services/Elastic.Changelog/Rendering/ChangelogRenderContext.cs
+++ b/src/services/Elastic.Changelog/Rendering/ChangelogRenderContext.cs
@@ -21,6 +21,7 @@ public record ChangelogRenderContext
public required IReadOnlyDictionary> EntriesByType { get; init; }
public required bool Subsections { get; init; }
public required bool Dropdowns { get; init; }
+ public required bool HideDescriptions { get; init; }
public required HashSet FeatureIdsToHide { get; init; }
public required Dictionary> EntryToBundleProducts { get; init; }
public required Dictionary EntryToRepo { get; init; }
diff --git a/src/services/Elastic.Changelog/Rendering/ChangelogRenderer.cs b/src/services/Elastic.Changelog/Rendering/ChangelogRenderer.cs
index 2f901c29d4..5e609cd243 100644
--- a/src/services/Elastic.Changelog/Rendering/ChangelogRenderer.cs
+++ b/src/services/Elastic.Changelog/Rendering/ChangelogRenderer.cs
@@ -33,6 +33,10 @@ public async Task RenderAsync(
await RenderMarkdownAsync(context, ctx);
break;
+ case ChangelogFileType.Gfm:
+ await RenderGfmAsync(context, ctx);
+ break;
+
default:
throw new ArgumentException($"Unknown changelog file type: {fileType}", nameof(fileType));
}
@@ -51,4 +55,11 @@ private async Task RenderMarkdownAsync(ChangelogRenderContext context, Cancel ct
await markdownRenderer.RenderAsync(context, ctx);
logger.LogInformation("Rendered changelog markdown files to {OutputDir}", context.OutputDir);
}
+
+ private async Task RenderGfmAsync(ChangelogRenderContext context, Cancel ctx)
+ {
+ var gfmRenderer = new ChangelogGfmRenderer(fileSystem);
+ await gfmRenderer.RenderAsync(context, ctx);
+ logger.LogInformation("Rendered changelog GFM file to {OutputDir}", context.OutputDir);
+ }
}
diff --git a/src/services/Elastic.Changelog/Rendering/ChangelogRenderingService.cs b/src/services/Elastic.Changelog/Rendering/ChangelogRenderingService.cs
index 068212a72a..0c8188b41b 100644
--- a/src/services/Elastic.Changelog/Rendering/ChangelogRenderingService.cs
+++ b/src/services/Elastic.Changelog/Rendering/ChangelogRenderingService.cs
@@ -32,6 +32,7 @@ public record RenderChangelogsArguments
public string[]? HideFeatures { get; init; }
public string? Config { get; init; }
public ChangelogFileType FileType { get; init; } = ChangelogFileType.Markdown;
+ public bool HideDescriptions { get; init; }
}
@@ -59,11 +60,14 @@ public enum ChangelogFileType
Markdown,
[Display(Name = "asciidoc")]
[JsonStringEnumMemberName("asciidoc")]
- Asciidoc
+ Asciidoc,
+ [Display(Name = "gfm")]
+ [JsonStringEnumMemberName("gfm")]
+ Gfm
}
///
-/// Service for rendering changelog output (markdown or asciidoc)
+/// Service for rendering changelog output (markdown, asciidoc, or gfm)
///
public class ChangelogRenderingService(
ILoggerFactory logFactory,
@@ -341,6 +345,7 @@ private static ChangelogRenderContext BuildRenderContext(
EntriesByType = entriesByType,
Subsections = input.Subsections,
Dropdowns = input.Dropdowns,
+ HideDescriptions = input.HideDescriptions,
FeatureIdsToHide = featureIdsToHide,
EntryToBundleProducts = entryToBundleProducts,
EntryToRepo = entryToRepo,
diff --git a/src/services/Elastic.Changelog/Rendering/Markdown/BreakingChangesMarkdownRenderer.cs b/src/services/Elastic.Changelog/Rendering/Markdown/BreakingChangesMarkdownRenderer.cs
index 4cee9acece..0434f2c902 100644
--- a/src/services/Elastic.Changelog/Rendering/Markdown/BreakingChangesMarkdownRenderer.cs
+++ b/src/services/Elastic.Changelog/Rendering/Markdown/BreakingChangesMarkdownRenderer.cs
@@ -68,7 +68,8 @@ public override async Task RenderAsync(ChangelogRenderContext context, Cancel ct
{
// Dropdown rendering (current logic)
_ = sb.AppendLine(InvariantCulture, $"::::{{dropdown}} {ChangelogTextUtilities.Beautify(entry.Title)}");
- _ = sb.AppendLine(entry.Description ?? "% Describe the functionality that changed");
+ if (!context.HideDescriptions)
+ _ = sb.AppendLine(entry.Description ?? "% Describe the functionality that changed");
_ = sb.AppendLine();
RenderPrIssueLinks(sb, new PrIssueLinkOptions(entry, entryRepo, entryOwner, entryHideLinks));
@@ -92,7 +93,7 @@ public override async Task RenderAsync(ChangelogRenderContext context, Cancel ct
_ = sb.AppendLine();
// Description with proper indentation
- if (!string.IsNullOrWhiteSpace(entry.Description))
+ if (!context.HideDescriptions && !string.IsNullOrWhiteSpace(entry.Description))
{
_ = sb.AppendLine(ChangelogTextUtilities.Indent(entry.Description));
_ = sb.AppendLine();
diff --git a/src/services/Elastic.Changelog/Rendering/Markdown/ChangelogGfmRenderer.cs b/src/services/Elastic.Changelog/Rendering/Markdown/ChangelogGfmRenderer.cs
new file mode 100644
index 0000000000..38a4afe3e5
--- /dev/null
+++ b/src/services/Elastic.Changelog/Rendering/Markdown/ChangelogGfmRenderer.cs
@@ -0,0 +1,293 @@
+// 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 System.Collections.Generic;
+using System.IO.Abstractions;
+using System.Text;
+using Elastic.Documentation.ReleaseNotes;
+using Nullean.ScopedFileSystem;
+using static System.Globalization.CultureInfo;
+using static Elastic.Documentation.ChangelogEntryType;
+
+namespace Elastic.Changelog.Rendering.Markdown;
+
+///
+/// Renderer for generating clean GitHub Flavored Markdown in a single changelog.md file
+///
+public class ChangelogGfmRenderer(ScopedFileSystem fileSystem) : MarkdownRendererBase(fileSystem)
+{
+ ///
+ public override string OutputFileName => "changelog.md";
+
+ ///
+ public override async Task RenderAsync(ChangelogRenderContext context, Cancel ctx)
+ {
+ var entriesByType = context.EntriesByType;
+ var features = entriesByType.GetValueOrDefault(Feature, []);
+ var enhancements = entriesByType.GetValueOrDefault(Enhancement, []);
+ var security = entriesByType.GetValueOrDefault(Security, []);
+ var bugFixes = entriesByType.GetValueOrDefault(BugFix, []);
+ var docs = entriesByType.GetValueOrDefault(Docs, []);
+ var regressions = entriesByType.GetValueOrDefault(Regression, []);
+ var other = entriesByType.GetValueOrDefault(Other, []);
+ var breakingChanges = entriesByType.GetValueOrDefault(BreakingChange, []);
+ var deprecations = entriesByType.GetValueOrDefault(Deprecation, []);
+ var knownIssues = entriesByType.GetValueOrDefault(KnownIssue, []);
+
+ // Check for highlights
+ var highlights = entriesByType.Values
+ .SelectMany(e => e)
+ .Where(e => e.Highlight == true)
+ .ToList();
+
+ var sb = new StringBuilder();
+
+ // Main heading - clean without anchors
+ _ = sb.AppendLine(InvariantCulture, $"## {context.Title}");
+
+ // Release date if present
+ if (context.BundleReleaseDate is { } releaseDate)
+ {
+ _ = sb.AppendLine();
+ _ = sb.AppendLine(InvariantCulture, $"_Released: {releaseDate.ToString("MMMM d, yyyy", InvariantCulture)}_");
+ }
+
+ // Add description if present
+ if (!string.IsNullOrEmpty(context.BundleDescription))
+ {
+ _ = sb.AppendLine();
+ _ = sb.AppendLine(context.BundleDescription);
+ }
+
+ _ = sb.AppendLine();
+
+ // Helper to check if all entries in a collection are hidden
+ bool AllEntriesHidden(IReadOnlyCollection entries) =>
+ entries.Count > 0 && entries.All(entry =>
+ ChangelogRenderUtilities.ShouldHideEntry(entry, context.FeatureIdsToHide, context));
+
+ // Render highlights first if any exist
+ if (highlights.Count > 0)
+ {
+ _ = sb.AppendLine("### Highlights");
+ RenderEntriesByArea(sb, highlights, context);
+ _ = sb.AppendLine();
+ }
+
+ // Features and enhancements
+ if (features.Count > 0 || enhancements.Count > 0)
+ {
+ var combined = features.Concat(enhancements).ToList();
+ if (!AllEntriesHidden(combined))
+ {
+ _ = sb.AppendLine("### Features and enhancements");
+ RenderEntriesByArea(sb, combined, context);
+ _ = sb.AppendLine();
+ }
+ }
+
+ // Breaking changes
+ if (breakingChanges.Count > 0 && !AllEntriesHidden(breakingChanges))
+ {
+ _ = sb.AppendLine("### Breaking changes");
+ RenderEntriesByArea(sb, breakingChanges, context);
+ _ = sb.AppendLine();
+ }
+
+ // Deprecations
+ if (deprecations.Count > 0 && !AllEntriesHidden(deprecations))
+ {
+ _ = sb.AppendLine("### Deprecations");
+ RenderEntriesByArea(sb, deprecations, context);
+ _ = sb.AppendLine();
+ }
+
+ // Bug fixes and security updates
+ if (security.Count > 0 || bugFixes.Count > 0)
+ {
+ var combined = security.Concat(bugFixes).ToList();
+ if (!AllEntriesHidden(combined))
+ {
+ _ = sb.AppendLine("### Bug fixes");
+ RenderEntriesByArea(sb, combined, context);
+ _ = sb.AppendLine();
+ }
+ }
+
+ // Known issues
+ if (knownIssues.Count > 0 && !AllEntriesHidden(knownIssues))
+ {
+ _ = sb.AppendLine("### Known issues");
+ RenderEntriesByArea(sb, knownIssues, context);
+ _ = sb.AppendLine();
+ }
+
+ // Documentation
+ if (docs.Count > 0 && !AllEntriesHidden(docs))
+ {
+ _ = sb.AppendLine("### Documentation");
+ RenderEntriesByArea(sb, docs, context);
+ _ = sb.AppendLine();
+ }
+
+ // Regressions
+ if (regressions.Count > 0 && !AllEntriesHidden(regressions))
+ {
+ _ = sb.AppendLine("### Regressions");
+ RenderEntriesByArea(sb, regressions, context);
+ _ = sb.AppendLine();
+ }
+
+ // Other changes
+ if (other.Count > 0 && !AllEntriesHidden(other))
+ {
+ _ = sb.AppendLine("### Other changes");
+ RenderEntriesByArea(sb, other, context);
+ _ = sb.AppendLine();
+ }
+
+ // Check if we have any visible content
+ var hasAnyVisibleContent = highlights.Count > 0 ||
+ (!AllEntriesHidden(features) && features.Count > 0) ||
+ (!AllEntriesHidden(enhancements) && enhancements.Count > 0) ||
+ (!AllEntriesHidden(breakingChanges) && breakingChanges.Count > 0) ||
+ (!AllEntriesHidden(deprecations) && deprecations.Count > 0) ||
+ (!AllEntriesHidden(security) && security.Count > 0) ||
+ (!AllEntriesHidden(bugFixes) && bugFixes.Count > 0) ||
+ (!AllEntriesHidden(knownIssues) && knownIssues.Count > 0) ||
+ (!AllEntriesHidden(docs) && docs.Count > 0) ||
+ (!AllEntriesHidden(regressions) && regressions.Count > 0) ||
+ (!AllEntriesHidden(other) && other.Count > 0);
+
+ if (!hasAnyVisibleContent)
+ {
+ _ = sb.AppendLine("_There are no new features, enhancements, or fixes associated with this release._");
+ _ = sb.AppendLine();
+ }
+
+ await WriteOutputFileAsync(context.OutputDir, context.TitleSlug, sb.ToString(), ctx);
+ }
+
+ private static void RenderEntriesByArea(
+ StringBuilder sb,
+ IReadOnlyCollection entries,
+ ChangelogRenderContext context)
+ {
+ var groupedByArea = context.Subsections
+ ? entries.GroupBy(e => ChangelogRenderUtilities.GetComponent(e, context)).OrderBy(g => g.Key).ToList()
+ : entries.GroupBy(e => ChangelogRenderUtilities.GetComponent(e, context)).ToList();
+
+ foreach (var areaGroup in groupedByArea)
+ {
+ // Check if all entries in this area group are hidden
+ var allEntriesHidden = areaGroup.All(entry =>
+ ChangelogRenderUtilities.ShouldHideEntry(entry, context.FeatureIdsToHide, context));
+
+ if (context.Subsections && !string.IsNullOrWhiteSpace(areaGroup.Key))
+ {
+ var header = ChangelogTextUtilities.FormatAreaHeader(areaGroup.Key);
+ if (allEntriesHidden)
+ _ = sb.Append("% ");
+ _ = sb.AppendLine(InvariantCulture, $"**{header}**");
+ _ = sb.AppendLine();
+ }
+
+ foreach (var entry in areaGroup)
+ {
+ var (entryRepo, entryOwner, entryHideLinks, shouldHide) = ChangelogRenderUtilities.GetEntryContext(entry, context);
+
+ if (shouldHide)
+ _ = sb.Append("% ");
+ _ = sb.Append("* ");
+ _ = sb.Append(ChangelogTextUtilities.Beautify(entry.Title));
+
+ var hasCommentedLinks = false;
+ if (entryHideLinks)
+ {
+ foreach (var pr in entry.Prs ?? [])
+ {
+ var formatted = ChangelogTextUtilities.FormatPrLink(pr, entryRepo, entryHideLinks, entryOwner);
+ if (string.IsNullOrEmpty(formatted))
+ continue;
+
+ _ = sb.AppendLine();
+ if (shouldHide)
+ _ = sb.Append("% ");
+ _ = sb.Append(" ");
+ _ = sb.Append(formatted);
+ hasCommentedLinks = true;
+ }
+
+ foreach (var issue in entry.Issues ?? [])
+ {
+ var formatted = ChangelogTextUtilities.FormatIssueLink(issue, entryRepo, entryHideLinks, entryOwner);
+ if (string.IsNullOrEmpty(formatted))
+ continue;
+
+ _ = sb.AppendLine();
+ if (shouldHide)
+ _ = sb.Append("% ");
+ _ = sb.Append(" ");
+ _ = sb.Append(formatted);
+ hasCommentedLinks = true;
+ }
+
+ if (hasCommentedLinks)
+ _ = sb.AppendLine();
+ }
+ else
+ {
+ var linkParts = new List();
+ foreach (var pr in entry.Prs ?? [])
+ {
+ var s = ChangelogTextUtilities.FormatPrLink(pr, entryRepo, entryHideLinks, entryOwner);
+ if (!string.IsNullOrEmpty(s))
+ linkParts.Add(s);
+ }
+
+ foreach (var issue in entry.Issues ?? [])
+ {
+ var s = ChangelogTextUtilities.FormatIssueLink(issue, entryRepo, entryHideLinks, entryOwner);
+ if (!string.IsNullOrEmpty(s))
+ linkParts.Add(s);
+ }
+
+ if (linkParts.Count > 0)
+ {
+ _ = sb.Append(' ');
+ var first = true;
+ foreach (var s in linkParts)
+ {
+ if (!first)
+ _ = sb.Append(' ');
+ _ = sb.Append(s);
+ first = false;
+ }
+ }
+ }
+
+ if (!context.HideDescriptions && !string.IsNullOrWhiteSpace(entry.Description))
+ {
+ _ = sb.AppendLine(entryHideLinks && hasCommentedLinks ? " " : "");
+ _ = sb.AppendLine();
+ var indented = ChangelogTextUtilities.Indent(entry.Description);
+ if (shouldHide)
+ {
+ // Comment out each line of the description
+ var indentedLines = indented.Split('\n');
+ foreach (var line in indentedLines)
+ {
+ _ = sb.Append("% ");
+ _ = sb.AppendLine(line);
+ }
+ }
+ else
+ _ = sb.AppendLine(indented);
+ }
+ else
+ _ = sb.AppendLine();
+ }
+ }
+ }
+}
diff --git a/src/services/Elastic.Changelog/Rendering/Markdown/DeprecationsMarkdownRenderer.cs b/src/services/Elastic.Changelog/Rendering/Markdown/DeprecationsMarkdownRenderer.cs
index c5b80228b4..048612f32d 100644
--- a/src/services/Elastic.Changelog/Rendering/Markdown/DeprecationsMarkdownRenderer.cs
+++ b/src/services/Elastic.Changelog/Rendering/Markdown/DeprecationsMarkdownRenderer.cs
@@ -65,7 +65,8 @@ public override async Task RenderAsync(ChangelogRenderContext context, Cancel ct
{
// Dropdown rendering (current logic)
_ = sb.AppendLine(InvariantCulture, $"::::{{dropdown}} {ChangelogTextUtilities.Beautify(entry.Title)}");
- _ = sb.AppendLine(entry.Description ?? "% Describe the functionality that was deprecated");
+ if (!context.HideDescriptions)
+ _ = sb.AppendLine(entry.Description ?? "% Describe the functionality that was deprecated");
_ = sb.AppendLine();
RenderPrIssueLinks(sb, new PrIssueLinkOptions(entry, entryRepo, entryOwner, entryHideLinks));
@@ -89,7 +90,7 @@ public override async Task RenderAsync(ChangelogRenderContext context, Cancel ct
_ = sb.AppendLine();
// Description with proper indentation
- if (!string.IsNullOrWhiteSpace(entry.Description))
+ if (!context.HideDescriptions && !string.IsNullOrWhiteSpace(entry.Description))
{
_ = sb.AppendLine(ChangelogTextUtilities.Indent(entry.Description));
_ = sb.AppendLine();
diff --git a/src/services/Elastic.Changelog/Rendering/Markdown/HighlightsMarkdownRenderer.cs b/src/services/Elastic.Changelog/Rendering/Markdown/HighlightsMarkdownRenderer.cs
index 5d5cf5a2e7..22d39044b9 100644
--- a/src/services/Elastic.Changelog/Rendering/Markdown/HighlightsMarkdownRenderer.cs
+++ b/src/services/Elastic.Changelog/Rendering/Markdown/HighlightsMarkdownRenderer.cs
@@ -68,7 +68,8 @@ public override async Task RenderAsync(ChangelogRenderContext context, Cancel ct
{
// Dropdown rendering (current logic)
_ = sb.AppendLine(InvariantCulture, $"::::{{dropdown}} {ChangelogTextUtilities.Beautify(entry.Title)}");
- _ = sb.AppendLine(entry.Description ?? "% Describe the highlight");
+ if (!context.HideDescriptions)
+ _ = sb.AppendLine(entry.Description ?? "% Describe the highlight");
_ = sb.AppendLine();
RenderPrIssueLinks(sb, new PrIssueLinkOptions(entry, entryRepo, entryOwner, entryHideLinks));
_ = sb.AppendLine("::::");
@@ -81,7 +82,7 @@ public override async Task RenderAsync(ChangelogRenderContext context, Cancel ct
_ = sb.AppendLine();
// Description with proper indentation
- if (!string.IsNullOrWhiteSpace(entry.Description))
+ if (!context.HideDescriptions && !string.IsNullOrWhiteSpace(entry.Description))
{
_ = sb.AppendLine(ChangelogTextUtilities.Indent(entry.Description));
_ = sb.AppendLine();
diff --git a/src/services/Elastic.Changelog/Rendering/Markdown/IndexMarkdownRenderer.cs b/src/services/Elastic.Changelog/Rendering/Markdown/IndexMarkdownRenderer.cs
index c310834d2b..73f92dee39 100644
--- a/src/services/Elastic.Changelog/Rendering/Markdown/IndexMarkdownRenderer.cs
+++ b/src/services/Elastic.Changelog/Rendering/Markdown/IndexMarkdownRenderer.cs
@@ -240,7 +240,7 @@ private static void RenderEntriesByArea(
}
}
- if (!string.IsNullOrWhiteSpace(entry.Description))
+ if (!context.HideDescriptions && !string.IsNullOrWhiteSpace(entry.Description))
{
_ = sb.AppendLine(entryHideLinks && hasCommentedLinks ? " " : "");
_ = sb.AppendLine();
diff --git a/src/services/Elastic.Changelog/Rendering/Markdown/KnownIssuesMarkdownRenderer.cs b/src/services/Elastic.Changelog/Rendering/Markdown/KnownIssuesMarkdownRenderer.cs
index 3f6e3b22da..5b9dbe521a 100644
--- a/src/services/Elastic.Changelog/Rendering/Markdown/KnownIssuesMarkdownRenderer.cs
+++ b/src/services/Elastic.Changelog/Rendering/Markdown/KnownIssuesMarkdownRenderer.cs
@@ -65,7 +65,8 @@ public override async Task RenderAsync(ChangelogRenderContext context, Cancel ct
{
// Dropdown rendering (current logic)
_ = sb.AppendLine(InvariantCulture, $"::::{{dropdown}} {ChangelogTextUtilities.Beautify(entry.Title)}");
- _ = sb.AppendLine(entry.Description ?? "% Describe the known issue");
+ if (!context.HideDescriptions)
+ _ = sb.AppendLine(entry.Description ?? "% Describe the known issue");
_ = sb.AppendLine();
RenderPrIssueLinks(sb, new PrIssueLinkOptions(entry, entryRepo, entryOwner, entryHideLinks));
@@ -89,7 +90,7 @@ public override async Task RenderAsync(ChangelogRenderContext context, Cancel ct
_ = sb.AppendLine();
// Description with proper indentation
- if (!string.IsNullOrWhiteSpace(entry.Description))
+ if (!context.HideDescriptions && !string.IsNullOrWhiteSpace(entry.Description))
{
_ = sb.AppendLine(ChangelogTextUtilities.Indent(entry.Description));
_ = sb.AppendLine();
diff --git a/src/tooling/docs-builder/Commands/ChangelogCommand.cs b/src/tooling/docs-builder/Commands/ChangelogCommand.cs
index 3b023cf912..909c56823e 100644
--- a/src/tooling/docs-builder/Commands/ChangelogCommand.cs
+++ b/src/tooling/docs-builder/Commands/ChangelogCommand.cs
@@ -1145,11 +1145,12 @@ async static (s, collector, state, ctx) => await s.RemoveChangelogs(collector, s
/// Render one or more changelog bundles to Markdown or AsciiDoc.
/// Required: Bundle input(s) in format "bundle-file-path|changelog-file-path|repo|link-visibility" (use pipe as delimiter). To merge multiple bundles, separate them with commas. Only bundle-file-path is required. link-visibility can be "hide-links" or "keep-links" (default). Use "hide-links" for private repositories; when set, all PR and issue links for each affected entry are hidden (entries may have multiple links via the prs and issues arrays). Paths support tilde (~) expansion and relative paths.
/// Optional: Path to the changelog.yml configuration file. Defaults to 'docs/changelog.yml'
- /// Optional: Output file type. Valid values: "markdown" or "asciidoc". Defaults to "markdown"
+ /// Optional: Output file type. Valid values: "markdown", "asciidoc", or "gfm". Defaults to "markdown"
/// 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 in the output.
/// Optional: Output directory for rendered files. Defaults to current directory
/// Optional: Group entries by area/component in subsections. For breaking changes with a subtype, groups by subtype instead of area. Defaults to false
/// Optional: Render separated types (breaking changes, deprecations, known issues, highlights) as MyST dropdowns. When false (default), renders as flattened bulleted lists. Defaults to false
+ /// Optional: Hide changelog record descriptions from output. When enabled, entry titles, PR/issue links, Impact and Action sections remain visible. Bundle-level descriptions are unaffected. Defaults to false
/// Optional: Title to use for section headers in output files. Defaults to version from first bundle
///
[NoOptionsInjection]
@@ -1161,6 +1162,7 @@ public async Task Render(
string? output = null,
bool subsections = false,
bool dropdowns = false,
+ bool noDescriptions = false,
string? title = null,
CancellationToken ct = default
)
@@ -1176,11 +1178,12 @@ public async Task Render(
{
"markdown" => ChangelogFileType.Markdown,
"asciidoc" => ChangelogFileType.Asciidoc,
+ "gfm" => ChangelogFileType.Gfm,
_ => null
};
if (ft is null)
{
- collector.EmitError(string.Empty, $"Invalid file-type '{fileType}'. Valid values are 'markdown' or 'asciidoc'.");
+ collector.EmitError(string.Empty, $"Invalid file-type '{fileType}'. Valid values are 'markdown', 'asciidoc', or 'gfm'.");
return 1;
}
@@ -1194,6 +1197,7 @@ public async Task Render(
Title = title,
Subsections = subsections,
Dropdowns = dropdowns,
+ HideDescriptions = noDescriptions,
HideFeatures = allFeatureIds.Count > 0 ? allFeatureIds.ToArray() : null,
FileType = ft.Value,
Config = config?.FullName
diff --git a/tests/Elastic.Changelog.Tests/Changelogs/Render/DescriptionVisibilityTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/Render/DescriptionVisibilityTests.cs
new file mode 100644
index 0000000000..2c3f6441fb
--- /dev/null
+++ b/tests/Elastic.Changelog.Tests/Changelogs/Render/DescriptionVisibilityTests.cs
@@ -0,0 +1,458 @@
+// 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 System.IO;
+using AwesomeAssertions;
+using Elastic.Changelog.Bundling;
+using Elastic.Changelog.Rendering;
+using Elastic.Documentation.Configuration;
+
+namespace Elastic.Changelog.Tests.Changelogs.Render;
+
+public class DescriptionVisibilityTests(ITestOutputHelper output) : RenderChangelogTestBase(output)
+{
+ [Fact]
+ public async Task RenderChangelogs_DefaultBehavior_IncludesDescriptionsInMarkdown()
+ {
+ // Arrange
+ var changelogDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString());
+ FileSystem.Directory.CreateDirectory(changelogDir);
+
+ // Create test changelog file with description
+ // language=yaml
+ var changelog1 =
+ """
+ title: Test feature with description
+ type: feature
+ products:
+ - product: elasticsearch
+ target: 9.2.0
+ prs:
+ - "100"
+ description: This is a detailed description of the test feature that should be visible by default.
+ """;
+
+ var changelogFile = FileSystem.Path.Join(changelogDir, "test-feature.yaml");
+ await FileSystem.File.WriteAllTextAsync(changelogFile, changelog1, TestContext.Current.CancellationToken);
+
+ // Create bundle file
+ var bundleFile = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml");
+ FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(bundleFile)!);
+
+ // language=yaml
+ var bundleContent =
+ $"""
+ products:
+ - product: elasticsearch
+ target: 9.2.0
+ entries:
+ - file:
+ name: test-feature.yaml
+ checksum: {ComputeSha1(changelog1)}
+ """;
+ await FileSystem.File.WriteAllTextAsync(bundleFile, bundleContent, TestContext.Current.CancellationToken);
+
+ var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString());
+
+ var input = new RenderChangelogsArguments
+ {
+ Bundles = [new BundleInput { BundleFile = bundleFile, Directory = changelogDir, Repo = "elasticsearch" }],
+ Output = outputDir,
+ FileType = ChangelogFileType.Markdown,
+ HideDescriptions = false // Default behavior
+ };
+
+ // Act
+ var result = await Service.RenderChangelogs(Collector, input, TestContext.Current.CancellationToken);
+
+ // Assert
+ result.Should().BeTrue();
+ Collector.Errors.Should().Be(0);
+
+ var indexMarkdown = FileSystem.Path.Join(outputDir, "9.2.0", "index.md");
+ FileSystem.File.Exists(indexMarkdown).Should().BeTrue();
+
+ var indexContent = await FileSystem.File.ReadAllTextAsync(indexMarkdown, TestContext.Current.CancellationToken);
+ indexContent.Should().Contain("Test feature with description");
+ indexContent.Should().Contain("This is a detailed description of the test feature that should be visible by default.");
+ indexContent.Should().Contain("[#100](https://github.com/elastic/elasticsearch/pull/100)");
+ }
+
+ [Fact]
+ public async Task RenderChangelogs_NoDescriptionsFlag_HidesDescriptionsInMarkdown()
+ {
+ // Arrange
+ var changelogDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString());
+ FileSystem.Directory.CreateDirectory(changelogDir);
+
+ // Create test changelog file with description
+ // language=yaml
+ var changelog1 =
+ """
+ title: Test feature with hidden description
+ type: feature
+ products:
+ - product: elasticsearch
+ target: 9.2.0
+ prs:
+ - "200"
+ description: This description should be hidden when --no-descriptions flag is used.
+ """;
+
+ var changelogFile = FileSystem.Path.Join(changelogDir, "test-feature.yaml");
+ await FileSystem.File.WriteAllTextAsync(changelogFile, changelog1, TestContext.Current.CancellationToken);
+
+ // Create bundle file
+ var bundleFile = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml");
+ FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(bundleFile)!);
+
+ // language=yaml
+ var bundleContent =
+ $"""
+ products:
+ - product: elasticsearch
+ target: 9.2.0
+ entries:
+ - file:
+ name: test-feature.yaml
+ checksum: {ComputeSha1(changelog1)}
+ """;
+ await FileSystem.File.WriteAllTextAsync(bundleFile, bundleContent, TestContext.Current.CancellationToken);
+
+ var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString());
+
+ var input = new RenderChangelogsArguments
+ {
+ Bundles = [new BundleInput { BundleFile = bundleFile, Directory = changelogDir, Repo = "elasticsearch" }],
+ Output = outputDir,
+ FileType = ChangelogFileType.Markdown,
+ HideDescriptions = true // Hide descriptions
+ };
+
+ // Act
+ var result = await Service.RenderChangelogs(Collector, input, TestContext.Current.CancellationToken);
+
+ // Assert
+ result.Should().BeTrue();
+ Collector.Errors.Should().Be(0);
+
+ var indexMarkdown = FileSystem.Path.Join(outputDir, "9.2.0", "index.md");
+ FileSystem.File.Exists(indexMarkdown).Should().BeTrue();
+
+ var indexContent = await FileSystem.File.ReadAllTextAsync(indexMarkdown, TestContext.Current.CancellationToken);
+
+ // Title and links should still be present
+ indexContent.Should().Contain("Test feature with hidden description");
+ indexContent.Should().Contain("[#200](https://github.com/elastic/elasticsearch/pull/200)");
+
+ // Description should be hidden
+ indexContent.Should().NotContain("This description should be hidden when --no-descriptions flag is used.");
+ }
+
+ [Fact]
+ public async Task RenderChangelogs_NoDescriptionsFlag_HidesDescriptionsInAsciidoc()
+ {
+ // Arrange
+ var changelogDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString());
+ FileSystem.Directory.CreateDirectory(changelogDir);
+
+ // Create test changelog file with description
+ // language=yaml
+ var changelog1 =
+ """
+ title: Test feature for asciidoc
+ type: feature
+ products:
+ - product: elasticsearch
+ target: 9.2.0
+ prs:
+ - "300"
+ description: This description should be hidden in asciidoc format when --no-descriptions is used.
+ """;
+
+ var changelogFile = FileSystem.Path.Join(changelogDir, "test-feature.yaml");
+ await FileSystem.File.WriteAllTextAsync(changelogFile, changelog1, TestContext.Current.CancellationToken);
+
+ // Create bundle file
+ var bundleFile = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml");
+ FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(bundleFile)!);
+
+ // language=yaml
+ var bundleContent =
+ $"""
+ products:
+ - product: elasticsearch
+ target: 9.2.0
+ entries:
+ - file:
+ name: test-feature.yaml
+ checksum: {ComputeSha1(changelog1)}
+ """;
+ await FileSystem.File.WriteAllTextAsync(bundleFile, bundleContent, TestContext.Current.CancellationToken);
+
+ var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString());
+
+ var input = new RenderChangelogsArguments
+ {
+ Bundles = [new BundleInput { BundleFile = bundleFile, Directory = changelogDir, Repo = "elasticsearch" }],
+ Output = outputDir,
+ FileType = ChangelogFileType.Asciidoc,
+ HideDescriptions = true // Hide descriptions
+ };
+
+ // Act
+ var result = await Service.RenderChangelogs(Collector, input, TestContext.Current.CancellationToken);
+
+ // Assert
+ result.Should().BeTrue();
+ Collector.Errors.Should().Be(0);
+
+ var asciidocFiles = FileSystem.Directory.GetFiles(outputDir, "*.asciidoc", SearchOption.AllDirectories);
+ asciidocFiles.Should().HaveCount(1);
+
+ var asciidocContent = await FileSystem.File.ReadAllTextAsync(asciidocFiles[0], TestContext.Current.CancellationToken);
+
+ // Title and links should still be present
+ asciidocContent.Should().Contain("Test feature for asciidoc");
+ asciidocContent.Should().Contain("{es-pull}300[#300]");
+
+ // Description should be hidden
+ asciidocContent.Should().NotContain("This description should be hidden in asciidoc format when --no-descriptions is used.");
+ }
+
+ [Fact]
+ public async Task RenderChangelogs_NoDescriptionsFlag_PreservesImpactAndActionForBreakingChanges()
+ {
+ // Arrange
+ var changelogDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString());
+ FileSystem.Directory.CreateDirectory(changelogDir);
+
+ // Create breaking change with description, impact, and action
+ // language=yaml
+ var changelog1 =
+ """
+ title: Breaking change test
+ type: breaking-change
+ products:
+ - product: elasticsearch
+ target: 9.2.0
+ prs:
+ - "400"
+ description: This description should be hidden but Impact and Action should remain.
+ impact: This is the impact section that should always be visible.
+ action: This is the action section that should always be visible.
+ """;
+
+ var changelogFile = FileSystem.Path.Join(changelogDir, "breaking-change.yaml");
+ await FileSystem.File.WriteAllTextAsync(changelogFile, changelog1, TestContext.Current.CancellationToken);
+
+ // Create bundle file
+ var bundleFile = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml");
+ FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(bundleFile)!);
+
+ // language=yaml
+ var bundleContent =
+ $"""
+ products:
+ - product: elasticsearch
+ target: 9.2.0
+ entries:
+ - file:
+ name: breaking-change.yaml
+ checksum: {ComputeSha1(changelog1)}
+ """;
+ await FileSystem.File.WriteAllTextAsync(bundleFile, bundleContent, TestContext.Current.CancellationToken);
+
+ var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString());
+
+ var input = new RenderChangelogsArguments
+ {
+ Bundles = [new BundleInput { BundleFile = bundleFile, Directory = changelogDir, Repo = "elasticsearch" }],
+ Output = outputDir,
+ FileType = ChangelogFileType.Markdown,
+ HideDescriptions = true,
+ Dropdowns = false // Test flattened mode
+ };
+
+ // Act
+ var result = await Service.RenderChangelogs(Collector, input, TestContext.Current.CancellationToken);
+
+ // Assert
+ result.Should().BeTrue();
+ Collector.Errors.Should().Be(0);
+
+ var breakingChangesMarkdown = FileSystem.Path.Join(outputDir, "9.2.0", "breaking-changes.md");
+ FileSystem.File.Exists(breakingChangesMarkdown).Should().BeTrue();
+
+ var breakingChangesContent = await FileSystem.File.ReadAllTextAsync(breakingChangesMarkdown, TestContext.Current.CancellationToken);
+
+ // Title and links should be present
+ breakingChangesContent.Should().Contain("Breaking change test");
+ breakingChangesContent.Should().Contain("[#400](https://github.com/elastic/elasticsearch/pull/400)");
+
+ // Description should be hidden
+ breakingChangesContent.Should().NotContain("This description should be hidden but Impact and Action should remain.");
+
+ // Impact and Action should still be visible
+ breakingChangesContent.Should().Contain("**Impact:** This is the impact section that should always be visible.");
+ breakingChangesContent.Should().Contain("**Action:** This is the action section that should always be visible.");
+ }
+
+ [Fact]
+ public async Task RenderChangelogs_NoDescriptionsFlag_WorksWithDropdownsMode()
+ {
+ // Arrange
+ var changelogDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString());
+ FileSystem.Directory.CreateDirectory(changelogDir);
+
+ // Create breaking change with description
+ // language=yaml
+ var changelog1 =
+ """
+ title: Breaking change for dropdown test
+ type: breaking-change
+ products:
+ - product: elasticsearch
+ target: 9.2.0
+ prs:
+ - "500"
+ description: This description should be hidden in dropdown mode.
+ impact: Impact visible in dropdown mode.
+ action: Action visible in dropdown mode.
+ """;
+
+ var changelogFile = FileSystem.Path.Join(changelogDir, "breaking-change.yaml");
+ await FileSystem.File.WriteAllTextAsync(changelogFile, changelog1, TestContext.Current.CancellationToken);
+
+ // Create bundle file
+ var bundleFile = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml");
+ FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(bundleFile)!);
+
+ // language=yaml
+ var bundleContent =
+ $"""
+ products:
+ - product: elasticsearch
+ target: 9.2.0
+ entries:
+ - file:
+ name: breaking-change.yaml
+ checksum: {ComputeSha1(changelog1)}
+ """;
+ await FileSystem.File.WriteAllTextAsync(bundleFile, bundleContent, TestContext.Current.CancellationToken);
+
+ var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString());
+
+ var input = new RenderChangelogsArguments
+ {
+ Bundles = [new BundleInput { BundleFile = bundleFile, Directory = changelogDir, Repo = "elasticsearch" }],
+ Output = outputDir,
+ FileType = ChangelogFileType.Markdown,
+ HideDescriptions = true,
+ Dropdowns = true // Test dropdown mode
+ };
+
+ // Act
+ var result = await Service.RenderChangelogs(Collector, input, TestContext.Current.CancellationToken);
+
+ // Assert
+ result.Should().BeTrue();
+ Collector.Errors.Should().Be(0);
+
+ var breakingChangesMarkdown = FileSystem.Path.Join(outputDir, "9.2.0", "breaking-changes.md");
+ FileSystem.File.Exists(breakingChangesMarkdown).Should().BeTrue();
+
+ var breakingChangesContent = await FileSystem.File.ReadAllTextAsync(breakingChangesMarkdown, TestContext.Current.CancellationToken);
+
+ // Should have dropdown structure
+ breakingChangesContent.Should().Contain("::::{dropdown} Breaking change for dropdown test");
+ breakingChangesContent.Should().Contain("::::");
+
+ // Links should be present
+ breakingChangesContent.Should().Contain("[#500](https://github.com/elastic/elasticsearch/pull/500)");
+
+ // Description should be hidden (no placeholder text either)
+ breakingChangesContent.Should().NotContain("This description should be hidden in dropdown mode.");
+ breakingChangesContent.Should().NotContain("% Describe the functionality that changed");
+
+ // Impact and Action should still be visible
+ breakingChangesContent.Should().Contain("**Impact**
Impact visible in dropdown mode.");
+ breakingChangesContent.Should().Contain("**Action**
Action visible in dropdown mode.");
+ }
+
+ [Fact]
+ public async Task RenderChangelogs_NoDescriptionsFlag_PreservesBundleDescription()
+ {
+ // Arrange
+ var changelogDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString());
+ FileSystem.Directory.CreateDirectory(changelogDir);
+
+ // Create test changelog file with description
+ // language=yaml
+ var changelog1 =
+ """
+ title: Test feature
+ type: feature
+ products:
+ - product: elasticsearch
+ target: 9.2.0
+ description: This entry description should be hidden.
+ """;
+
+ var changelogFile = FileSystem.Path.Join(changelogDir, "test-feature.yaml");
+ await FileSystem.File.WriteAllTextAsync(changelogFile, changelog1, TestContext.Current.CancellationToken);
+
+ // Create bundle file with bundle-level description
+ var bundleFile = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml");
+ FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(bundleFile)!);
+
+ // language=yaml
+ var bundleContent =
+ $"""
+ products:
+ - product: elasticsearch
+ target: 9.2.0
+ description: |
+ This is the bundle-level description that should always be visible
+ regardless of the --no-descriptions flag.
+ entries:
+ - file:
+ name: test-feature.yaml
+ checksum: {ComputeSha1(changelog1)}
+ """;
+ await FileSystem.File.WriteAllTextAsync(bundleFile, bundleContent, TestContext.Current.CancellationToken);
+
+ var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString());
+
+ var input = new RenderChangelogsArguments
+ {
+ Bundles = [new BundleInput { BundleFile = bundleFile, Directory = changelogDir, Repo = "elasticsearch" }],
+ Output = outputDir,
+ FileType = ChangelogFileType.Markdown,
+ HideDescriptions = true
+ };
+
+ // Act
+ var result = await Service.RenderChangelogs(Collector, input, TestContext.Current.CancellationToken);
+
+ // Assert
+ result.Should().BeTrue();
+ Collector.Errors.Should().Be(0);
+
+ var indexMarkdown = FileSystem.Path.Join(outputDir, "9.2.0", "index.md");
+ FileSystem.File.Exists(indexMarkdown).Should().BeTrue();
+
+ var indexContent = await FileSystem.File.ReadAllTextAsync(indexMarkdown, TestContext.Current.CancellationToken);
+
+ // Bundle description should be visible
+ indexContent.Should().Contain("This is the bundle-level description that should always be visible");
+ indexContent.Should().Contain("regardless of the --no-descriptions flag.");
+
+ // Entry title should be present
+ indexContent.Should().Contain("Test feature");
+
+ // Entry description should be hidden
+ indexContent.Should().NotContain("This entry description should be hidden.");
+ }
+}
diff --git a/tests/Elastic.Changelog.Tests/Changelogs/Render/GfmRenderTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/Render/GfmRenderTests.cs
new file mode 100644
index 0000000000..e6e71524e6
--- /dev/null
+++ b/tests/Elastic.Changelog.Tests/Changelogs/Render/GfmRenderTests.cs
@@ -0,0 +1,480 @@
+// 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 AwesomeAssertions;
+using Elastic.Changelog.Bundling;
+using Elastic.Changelog.Rendering;
+using Elastic.Documentation.Configuration;
+
+namespace Elastic.Changelog.Tests.Changelogs.Render;
+
+public class GfmRenderTests(ITestOutputHelper output) : RenderChangelogTestBase(output)
+{
+ [Fact]
+ public async Task RenderChangelogs_WithGfmFileType_CreatesSingleGfmFile()
+ {
+ // Arrange
+ var changelogDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString());
+ FileSystem.Directory.CreateDirectory(changelogDir);
+
+ // Create test changelog file
+ // language=yaml
+ var changelog =
+ """
+ title: Test feature
+ type: feature
+ products:
+ - product: elasticsearch
+ target: 9.2.0
+ prs:
+ - "100"
+ description: This is a test feature
+ """;
+
+ var changelogFile = FileSystem.Path.Join(changelogDir, "test-feature.yaml");
+ await FileSystem.File.WriteAllTextAsync(changelogFile, changelog, TestContext.Current.CancellationToken);
+
+ // Create bundle file
+ var bundleFile = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml");
+ FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(bundleFile)!);
+
+ // language=yaml
+ var bundleContent =
+ $"""
+ products:
+ - product: elasticsearch
+ target: 9.2.0
+ entries:
+ - file:
+ name: test-feature.yaml
+ checksum: {ComputeSha1(changelog)}
+ """;
+ await FileSystem.File.WriteAllTextAsync(bundleFile, bundleContent, TestContext.Current.CancellationToken);
+
+ var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString());
+
+ var input = new RenderChangelogsArguments
+ {
+ Bundles = [new BundleInput { BundleFile = bundleFile, Directory = changelogDir, Repo = "elasticsearch" }],
+ Output = outputDir,
+ Title = "9.2.0",
+ FileType = ChangelogFileType.Gfm
+ };
+
+ // Act
+ var result = await Service.RenderChangelogs(Collector, input, TestContext.Current.CancellationToken);
+
+ // Assert
+ result.Should().BeTrue();
+ Collector.Errors.Should().Be(0);
+
+ // Should create only changelog.md file, not multiple files like regular markdown
+ var outputFile = FileSystem.Path.Join(outputDir, "9.2.0", "changelog.md");
+ FileSystem.File.Exists(outputFile).Should().BeTrue();
+
+ // Should NOT create separate files
+ var indexFile = FileSystem.Path.Join(outputDir, "9.2.0", "index.md");
+ var breakingChangesFile = FileSystem.Path.Join(outputDir, "9.2.0", "breaking-changes.md");
+ FileSystem.File.Exists(indexFile).Should().BeFalse();
+ FileSystem.File.Exists(breakingChangesFile).Should().BeFalse();
+
+ var content = await FileSystem.File.ReadAllTextAsync(outputFile, TestContext.Current.CancellationToken);
+ content.Should().Contain("## 9.2.0");
+ content.Should().Contain("### Features and enhancements");
+ content.Should().Contain("Test feature");
+ content.Should().Contain("[#100](https://github.com/elastic/elasticsearch/pull/100)");
+
+ // Should NOT contain anchor brackets in headings
+ content.Should().NotContain("## 9.2.0 [");
+ content.Should().NotContain("### Features and enhancements [");
+ }
+
+ [Fact]
+ public async Task RenderChangelogs_WithGfmFileType_IncludesAllSectionTypes()
+ {
+ // Arrange
+ var changelogDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString());
+ FileSystem.Directory.CreateDirectory(changelogDir);
+
+ // Create test changelog files for different types
+ // language=yaml
+ var feature =
+ """
+ title: New feature
+ type: feature
+ products:
+ - product: elasticsearch
+ target: 9.2.0
+ """;
+
+ // language=yaml
+ var breakingChange =
+ """
+ title: Breaking change
+ type: breaking-change
+ products:
+ - product: elasticsearch
+ target: 9.2.0
+ """;
+
+ // language=yaml
+ var deprecation =
+ """
+ title: Deprecated API
+ type: deprecation
+ products:
+ - product: elasticsearch
+ target: 9.2.0
+ """;
+
+ // language=yaml
+ var bugFix =
+ """
+ title: Bug fix
+ type: bug-fix
+ products:
+ - product: elasticsearch
+ target: 9.2.0
+ """;
+
+ // language=yaml
+ var knownIssue =
+ """
+ title: Known issue
+ type: known-issue
+ products:
+ - product: elasticsearch
+ target: 9.2.0
+ """;
+
+ var featureFile = FileSystem.Path.Join(changelogDir, "feature.yaml");
+ var breakingFile = FileSystem.Path.Join(changelogDir, "breaking.yaml");
+ var deprecationFile = FileSystem.Path.Join(changelogDir, "deprecation.yaml");
+ var bugFixFile = FileSystem.Path.Join(changelogDir, "bugfix.yaml");
+ var knownIssueFile = FileSystem.Path.Join(changelogDir, "known-issue.yaml");
+
+ await FileSystem.File.WriteAllTextAsync(featureFile, feature, TestContext.Current.CancellationToken);
+ await FileSystem.File.WriteAllTextAsync(breakingFile, breakingChange, TestContext.Current.CancellationToken);
+ await FileSystem.File.WriteAllTextAsync(deprecationFile, deprecation, TestContext.Current.CancellationToken);
+ await FileSystem.File.WriteAllTextAsync(bugFixFile, bugFix, TestContext.Current.CancellationToken);
+ await FileSystem.File.WriteAllTextAsync(knownIssueFile, knownIssue, TestContext.Current.CancellationToken);
+
+ // Create bundle file
+ var bundleFile = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml");
+ FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(bundleFile)!);
+
+ // language=yaml
+ var bundleContent =
+ $"""
+ products:
+ - product: elasticsearch
+ target: 9.2.0
+ entries:
+ - file:
+ name: feature.yaml
+ checksum: {ComputeSha1(feature)}
+ - file:
+ name: breaking.yaml
+ checksum: {ComputeSha1(breakingChange)}
+ - file:
+ name: deprecation.yaml
+ checksum: {ComputeSha1(deprecation)}
+ - file:
+ name: bugfix.yaml
+ checksum: {ComputeSha1(bugFix)}
+ - file:
+ name: known-issue.yaml
+ checksum: {ComputeSha1(knownIssue)}
+ """;
+ await FileSystem.File.WriteAllTextAsync(bundleFile, bundleContent, TestContext.Current.CancellationToken);
+
+ var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString());
+
+ var input = new RenderChangelogsArguments
+ {
+ Bundles = [new BundleInput { BundleFile = bundleFile, Directory = changelogDir, Repo = "elasticsearch" }],
+ Output = outputDir,
+ Title = "9.2.0",
+ FileType = ChangelogFileType.Gfm
+ };
+
+ // Act
+ var result = await Service.RenderChangelogs(Collector, input, TestContext.Current.CancellationToken);
+
+ // Assert
+ result.Should().BeTrue();
+ Collector.Errors.Should().Be(0);
+
+ var outputChangelogPath = FileSystem.Path.Join(outputDir, "9.2.0", "changelog.md");
+ var content = await FileSystem.File.ReadAllTextAsync(outputChangelogPath, TestContext.Current.CancellationToken);
+
+ // Should include all section types in the proper order
+ content.Should().Contain("### Features and enhancements");
+ content.Should().Contain("### Breaking changes");
+ content.Should().Contain("### Deprecations");
+ content.Should().Contain("### Bug fixes");
+ content.Should().Contain("### Known issues");
+
+ // Should contain the entry titles
+ content.Should().Contain("New feature");
+ content.Should().Contain("Breaking change");
+ content.Should().Contain("Deprecated API");
+ content.Should().Contain("Bug fix");
+ content.Should().Contain("Known issue");
+
+ // Check section ordering (features should come before breaking changes)
+ var featuresIndex = content.IndexOf("### Features and enhancements", StringComparison.Ordinal);
+ var breakingIndex = content.IndexOf("### Breaking changes", StringComparison.Ordinal);
+ var deprecationIndex = content.IndexOf("### Deprecations", StringComparison.Ordinal);
+ var bugFixIndex = content.IndexOf("### Bug fixes", StringComparison.Ordinal);
+ var knownIssueIndex = content.IndexOf("### Known issues", StringComparison.Ordinal);
+
+ featuresIndex.Should().BeLessThan(breakingIndex);
+ breakingIndex.Should().BeLessThan(deprecationIndex);
+ deprecationIndex.Should().BeLessThan(bugFixIndex);
+ bugFixIndex.Should().BeLessThan(knownIssueIndex);
+ }
+
+ [Fact]
+ public async Task RenderChangelogs_WithGfmFileType_HandlesHighlights()
+ {
+ // Arrange
+ var changelogDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString());
+ FileSystem.Directory.CreateDirectory(changelogDir);
+
+ // Create test changelog with highlight
+ // language=yaml
+ var highlightedFeature =
+ """
+ title: Important new feature
+ type: feature
+ highlight: true
+ products:
+ - product: elasticsearch
+ target: 9.2.0
+ """;
+
+ // language=yaml
+ var normalFeature =
+ """
+ title: Regular feature
+ type: feature
+ products:
+ - product: elasticsearch
+ target: 9.2.0
+ """;
+
+ var highlightFile = FileSystem.Path.Join(changelogDir, "highlight.yaml");
+ var normalFile = FileSystem.Path.Join(changelogDir, "normal.yaml");
+
+ await FileSystem.File.WriteAllTextAsync(highlightFile, highlightedFeature, TestContext.Current.CancellationToken);
+ await FileSystem.File.WriteAllTextAsync(normalFile, normalFeature, TestContext.Current.CancellationToken);
+
+ // Create bundle file
+ var bundleFile = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml");
+ FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(bundleFile)!);
+
+ // language=yaml
+ var bundleContent =
+ $"""
+ products:
+ - product: elasticsearch
+ target: 9.2.0
+ entries:
+ - file:
+ name: highlight.yaml
+ checksum: {ComputeSha1(highlightedFeature)}
+ - file:
+ name: normal.yaml
+ checksum: {ComputeSha1(normalFeature)}
+ """;
+ await FileSystem.File.WriteAllTextAsync(bundleFile, bundleContent, TestContext.Current.CancellationToken);
+
+ var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString());
+
+ var input = new RenderChangelogsArguments
+ {
+ Bundles = [new BundleInput { BundleFile = bundleFile, Directory = changelogDir, Repo = "elasticsearch" }],
+ Output = outputDir,
+ Title = "9.2.0",
+ FileType = ChangelogFileType.Gfm
+ };
+
+ // Act
+ var result = await Service.RenderChangelogs(Collector, input, TestContext.Current.CancellationToken);
+
+ // Assert
+ result.Should().BeTrue();
+ Collector.Errors.Should().Be(0);
+
+ var highlightsOutputFile = FileSystem.Path.Join(outputDir, "9.2.0", "changelog.md");
+ var content = await FileSystem.File.ReadAllTextAsync(highlightsOutputFile, TestContext.Current.CancellationToken);
+
+ // Should include highlights section first
+ content.Should().Contain("### Highlights");
+ content.Should().Contain("### Features and enhancements");
+
+ // Highlights should come first
+ var highlightsIndex = content.IndexOf("### Highlights", StringComparison.Ordinal);
+ var featuresIndex = content.IndexOf("### Features and enhancements", StringComparison.Ordinal);
+ highlightsIndex.Should().BeLessThan(featuresIndex);
+
+ // Both features should be present
+ content.Should().Contain("Important new feature");
+ content.Should().Contain("Regular feature");
+ }
+
+ [Fact]
+ public async Task RenderChangelogs_WithGfmFileType_HandlesDescriptionsAndHideDescriptions()
+ {
+ // Arrange
+ var changelogDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString());
+ FileSystem.Directory.CreateDirectory(changelogDir);
+
+ // Create test changelog with description
+ // language=yaml
+ var changelog =
+ """
+ title: Feature with description
+ type: feature
+ products:
+ - product: elasticsearch
+ target: 9.2.0
+ description: |
+ This is a detailed description of the feature.
+ It spans multiple lines.
+ """;
+
+ var changelogFile = FileSystem.Path.Join(changelogDir, "feature.yaml");
+ await FileSystem.File.WriteAllTextAsync(changelogFile, changelog, TestContext.Current.CancellationToken);
+
+ // Create bundle file
+ var bundleFile = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml");
+ FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(bundleFile)!);
+
+ // language=yaml
+ var bundleContent =
+ $"""
+ products:
+ - product: elasticsearch
+ target: 9.2.0
+ entries:
+ - file:
+ name: feature.yaml
+ checksum: {ComputeSha1(changelog)}
+ """;
+ await FileSystem.File.WriteAllTextAsync(bundleFile, bundleContent, TestContext.Current.CancellationToken);
+
+ var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString());
+
+ // Test with descriptions shown (default)
+ var inputWithDescriptions = new RenderChangelogsArguments
+ {
+ Bundles = [new BundleInput { BundleFile = bundleFile, Directory = changelogDir, Repo = "elasticsearch" }],
+ Output = outputDir,
+ Title = "9.2.0",
+ FileType = ChangelogFileType.Gfm,
+ HideDescriptions = false
+ };
+
+ // Act
+ var result = await Service.RenderChangelogs(Collector, inputWithDescriptions, TestContext.Current.CancellationToken);
+
+ // Assert
+ result.Should().BeTrue();
+ Collector.Errors.Should().Be(0);
+
+ var outputChangelogFile = FileSystem.Path.Join(outputDir, "9.2.0", "changelog.md");
+ var content = await FileSystem.File.ReadAllTextAsync(outputChangelogFile, TestContext.Current.CancellationToken);
+
+ content.Should().Contain("Feature with description");
+ content.Should().Contain("This is a detailed description of the feature.");
+
+ // Test with descriptions hidden
+ var outputDir2 = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString());
+ var inputWithoutDescriptions = new RenderChangelogsArguments
+ {
+ Bundles = [new BundleInput { BundleFile = bundleFile, Directory = changelogDir, Repo = "elasticsearch" }],
+ Output = outputDir2,
+ Title = "9.2.0",
+ FileType = ChangelogFileType.Gfm,
+ HideDescriptions = true
+ };
+
+ var result2 = await Service.RenderChangelogs(Collector, inputWithoutDescriptions, TestContext.Current.CancellationToken);
+
+ result2.Should().BeTrue();
+ var changelogFile2 = FileSystem.Path.Join(outputDir2, "9.2.0", "changelog.md");
+ var content2 = await FileSystem.File.ReadAllTextAsync(changelogFile2, TestContext.Current.CancellationToken);
+
+ content2.Should().Contain("Feature with description");
+ content2.Should().NotContain("This is a detailed description of the feature.");
+ }
+
+ [Fact]
+ public async Task RenderChangelogs_WithGfmFileType_HandlesBundleDescriptionAndReleaseDate()
+ {
+ // Arrange
+ var changelogDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString());
+ FileSystem.Directory.CreateDirectory(changelogDir);
+
+ // Create simple changelog
+ // language=yaml
+ var changelog =
+ """
+ title: Simple feature
+ type: feature
+ products:
+ - product: elasticsearch
+ target: 9.2.0
+ """;
+
+ var changelogFile = FileSystem.Path.Join(changelogDir, "feature.yaml");
+ await FileSystem.File.WriteAllTextAsync(changelogFile, changelog, TestContext.Current.CancellationToken);
+
+ // Create bundle file with description and release date
+ var bundleFile = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml");
+ FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(bundleFile)!);
+
+ // language=yaml
+ var bundleContent =
+ $"""
+ products:
+ - product: elasticsearch
+ target: 9.2.0
+ description: "This is a major release with many improvements."
+ release-date: "2024-03-15"
+ entries:
+ - file:
+ name: feature.yaml
+ checksum: {ComputeSha1(changelog)}
+ """;
+ await FileSystem.File.WriteAllTextAsync(bundleFile, bundleContent, TestContext.Current.CancellationToken);
+
+ var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString());
+
+ var input = new RenderChangelogsArguments
+ {
+ Bundles = [new BundleInput { BundleFile = bundleFile, Directory = changelogDir, Repo = "elasticsearch" }],
+ Output = outputDir,
+ Title = "9.2.0",
+ FileType = ChangelogFileType.Gfm
+ };
+
+ // Act
+ var result = await Service.RenderChangelogs(Collector, input, TestContext.Current.CancellationToken);
+
+ // Assert
+ result.Should().BeTrue();
+ Collector.Errors.Should().Be(0);
+
+ var finalChangelogFile = FileSystem.Path.Join(outputDir, "9.2.0", "changelog.md");
+ var content = await FileSystem.File.ReadAllTextAsync(finalChangelogFile, TestContext.Current.CancellationToken);
+
+ // Should include the bundle description and release date
+ content.Should().Contain("## 9.2.0");
+ content.Should().Contain("_Released: March 15, 2024_");
+ content.Should().Contain("This is a major release with many improvements.");
+ }
+}