From bee63429de873e20bd7671686b89b6b81f3df893 Mon Sep 17 00:00:00 2001 From: lcawl Date: Wed, 8 Apr 2026 12:03:14 -0700 Subject: [PATCH 1/7] Add changelog bundle description --- config/changelog.example.yml | 13 +++ docs/cli/changelog/bundle.md | 56 ++++++++++++ docs/cli/changelog/gh-release.md | 11 +++ docs/contribute/changelog.md | 69 ++++++++++++++ docs/syntax/changelog.md | 15 +++- .../Changelog/BundleConfiguration.cs | 12 +++ .../ReleaseNotes/Bundle.cs | 4 + .../ReleaseNotes/BundleLoader.cs | 18 +++- .../ReleaseNotes/ReleaseNotesSerialization.cs | 2 + .../ReleaseNotes/Bundle.cs | 6 ++ .../Changelog/ChangelogInlineRenderer.cs | 12 ++- .../Bundling/ChangelogBundlingService.cs | 75 +++++++++++++++- .../ChangelogConfigurationLoader.cs | 2 + .../GitHubReleaseChangelogService.cs | 15 +++- .../Asciidoc/ChangelogAsciidocRenderer.cs | 7 ++ .../Rendering/ChangelogRenderContext.cs | 4 + .../Rendering/ChangelogRenderingService.cs | 27 +++++- .../Markdown/IndexMarkdownRenderer.cs | 7 ++ .../ChangelogConfigurationYaml.cs | 10 +++ .../docs-builder/Commands/ChangelogCommand.cs | 23 ++++- .../BundleLoading/BundleLoaderTests.cs | 89 +++++++++++++++++++ 21 files changed, 460 insertions(+), 17 deletions(-) diff --git a/config/changelog.example.yml b/config/changelog.example.yml index b09d71b2e..812c96679 100644 --- a/config/changelog.example.yml +++ b/config/changelog.example.yml @@ -212,6 +212,12 @@ bundle: output_directory: docs/releases # Whether to resolve (copy contents) by default resolve: true + # Optional: default description text for bundles. Supports {version}, {lifecycle}, {owner}, and {repo} placeholders. + # Use YAML literal block scalar (|) for multiline descriptions. See docs/contribute/changelog.md for examples. + # description: | + # 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}). # 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). # When omitted, no link filtering is applied. @@ -242,6 +248,13 @@ bundle: # output: "elasticsearch-{version}.yaml" # # Optional: override the products array written to the bundle output. # # output_products: "elasticsearch {version}" + # # Optional: profile-specific description (overrides bundle.description) + # # description: | + # # Elasticsearch {version} includes: + # # - Performance improvements + # # - Bug fixes and stability enhancements + # # + # # Download the release binaries: https://github.com/{owner}/{repo}/releases/tag/v{version} # 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/cli/changelog/bundle.md b/docs/cli/changelog/bundle.md index 6fe684316..6250f9408 100644 --- a/docs/cli/changelog/bundle.md +++ b/docs/cli/changelog/bundle.md @@ -78,6 +78,13 @@ You must choose one method for determining what's in the bundle (`--all`, `--inp : Optional: The directory that contains the changelog YAML files. : When not specified, falls back to `bundle.directory` from the changelog configuration, then the current working directory. See [Output files](#output-files) for the full resolution order. +`--description ` +: Optional: Bundle description text with placeholder support. +: Supports `{version}`, `{lifecycle}`, `{owner}`, and `{repo}` placeholders. Overrides `bundle.description` from config. +: When using `{version}` or `{lifecycle}` placeholders, predictable substitution values are required: +: - **Option-based mode**: Requires `--output-products` to be explicitly specified +: - **Profile-based mode**: Requires either a version argument OR `output_products` in the profile configuration + `--hide-features ` : Optional: A list of feature IDs (comma-separated), or a path to a newline-delimited file containing feature IDs. : Can be specified multiple times. @@ -354,6 +361,33 @@ docs-builder changelog bundle \ By default all changelogs that match PRs in the GitHub release notes are included in the bundle. To apply additional filtering by the changelog type, areas, or products, add `rules.bundle` [filters](#changelog-bundle-rules). +### Bundle with description + +You can add a description to bundles using the `--description` option. For simple descriptions, use regular quotes: + +```sh +docs-builder changelog bundle \ + --all \ + --description "This release includes new features and bug fixes." +``` + +For multiline descriptions with multiple paragraphs, lists, and links, use ANSI-C quoting (`$'...'`) with `\n` for line breaks: + +```sh +docs-builder changelog bundle \ + --all \ + --description $'This release includes significant improvements:\n\n- Enhanced performance\n- Bug fixes and stability improvements\n\nFor more information, see the [release notes](https://example.com/docs).' +``` + +When using placeholders in option-based mode, you must explicitly specify `--output-products` for predictable substitution: + +```sh +docs-builder changelog bundle \ + --all \ + --output-products "elasticsearch 9.1.0 ga" \ + --description "Elasticsearch {version} includes performance improvements. Download: https://github.com/{owner}/{repo}/releases/tag/v{version}" +``` + ## Profile-based examples When the changelog configuration file defines `bundle.profiles`, you can use those profiles with the `changelog bundle` command. @@ -442,6 +476,28 @@ docs-builder changelog bundle elasticsearch-gh-release 9.2.0 docs-builder changelog bundle elasticsearch-gh-release latest ``` +:::{warning} +**Placeholder validation**: If your profile uses `{version}` or `{lifecycle}` placeholders in the description, you must ensure predictable substitution values: + +```sh +# ✅ Good: Version provided for placeholder substitution +docs-builder changelog bundle elasticsearch-release 9.2.0 ./report.html + +# ❌ Bad: No version, placeholders will fail unless profile has output_products +docs-builder changelog bundle elasticsearch-release ./report.html +``` + +To fix the second case, either provide a version argument or add an `output_products` pattern to your profile: + +```yaml +profiles: + elasticsearch-release: + products: "elasticsearch * *" + output_products: "elasticsearch {version}" # Enables placeholder substitution + description: "Download: https://github.com/{owner}/{repo}/releases/tag/v{version}" +``` +::: + ### Bundle by product You can create profiles that are equivalent to the `--input-products` filter option, that is to say the bundle will contain only changelogs with matching `products`. diff --git a/docs/cli/changelog/gh-release.md b/docs/cli/changelog/gh-release.md index d89984f77..0c36ec80a 100644 --- a/docs/cli/changelog/gh-release.md +++ b/docs/cli/changelog/gh-release.md @@ -31,6 +31,10 @@ docs-builder changelog gh-release [version] [options...] [-h|--help] `--config ` : Optional: Path to the changelog.yml configuration file. Defaults to `docs/changelog.yml`. +`--description ` +: Optional: Bundle description text with placeholder support. +: Supports `{version}`, `{lifecycle}`, `{owner}`, and `{repo}` placeholders. Overrides `bundle.description` from config. + `--output ` : Optional: Output directory for the generated changelog files. Falls back to `bundle.directory` in `changelog.yml` when not specified. Defaults to `./changelogs`. @@ -86,6 +90,13 @@ docs-builder changelog gh-release elasticsearch v9.2.0 \ --config ./docs/changelog.yml ``` +### Add description with placeholders + +```sh +docs-builder changelog gh-release elasticsearch v9.2.0 \ + --description "Elasticsearch {version} includes new features and fixes. Download: https://github.com/{owner}/{repo}/releases/tag/v{version}" +``` + ### Strip component prefixes from titles ```sh diff --git a/docs/contribute/changelog.md b/docs/contribute/changelog.md index 9d261dc0b..a7b3017de 100644 --- a/docs/contribute/changelog.md +++ b/docs/contribute/changelog.md @@ -746,11 +746,23 @@ bundle: owner: elastic # The default repository owner for PR and issue links. directory: docs/changelog # The directory that contains changelog files. output_directory: docs/releases # The directory that contains changelog bundles. + # Optional: Default bundle description with placeholder support + description: | + This release includes new features and bug fixes. + + For more information, see the [release notes](https://www.elastic.co/docs/release-notes/elasticsearch#elasticsearch-{version}). profiles: elasticsearch-release: products: "elasticsearch {version} {lifecycle}" output: "elasticsearch/{version}.yaml" output_products: "elasticsearch {version}" + # Profile-specific description overrides bundle.description + description: | + Elasticsearch {version} includes: + - Performance improvements + - Bug fixes and stability enhancements + + Download the release binaries: https://github.com/{owner}/{repo}/releases/tag/v{version} hide_features: - feature:experimental-api serverless-release: @@ -800,6 +812,63 @@ The `{version}` placeholder is substituted with the clean base version extracted This differs from standard profiles, where `{lifecycle}` is inferred from the version string you type at the command line. +#### Bundle descriptions + +You can add introductory text to bundles using the `description` field. This text appears at the top of rendered changelogs, after the release heading but before the entry sections. + +**Configuration locations:** + +- `bundle.description`: Default description for all profiles +- `bundle.profiles..description`: Profile-specific description (overrides the default) + +**Placeholder support:** + +Bundle descriptions support these placeholders: + +- `{version}`: The resolved version string +- `{lifecycle}`: The resolved lifecycle (ga, beta, preview, etc.) +- `{owner}`: The GitHub repository owner +- `{repo}`: The GitHub repository name + +**Important**: When using `{version}` or `{lifecycle}` placeholders, you must ensure predictable substitution values: + +- **Option-based mode**: Requires `--output-products` when using placeholders +- **Profile-based mode**: Requires either a version argument (e.g., `bundle profile 9.2.0`) OR an `output_products` pattern in the profile configuration when using placeholders. If you invoke a profile with only a promotion report (e.g., `bundle profile ./report.html`), placeholders will fail unless `output_products` is configured. + +**Multiline descriptions in YAML:** + +For complex descriptions with multiple paragraphs, lists, and links, use YAML literal block scalars with the `|` (pipe) syntax: + +```yaml +bundle: + description: | + This release includes significant improvements: + + - Enhanced performance + - Bug fixes and stability improvements + - New features for better user experience + + For more information, see the [release notes](https://example.com/docs/release-notes). + + Download the release binaries: https://github.com/{owner}/{repo}/releases/tag/v{version} +``` + +The `|` (pipe) preserves line breaks and is ideal for Markdown-formatted text. Avoid using `>` (greater than) for descriptions as it folds line breaks into spaces, making lists and paragraphs difficult to format correctly. + +**Command line usage:** + +For simple descriptions, use the `--description` option with regular quotes: + +```sh +docs-builder changelog bundle --all --description "This release includes new features." +``` + +For multiline descriptions on the command line, use ANSI-C quoting (`$'...'`) with `\n` for line breaks: + +```sh +docs-builder changelog bundle --all --description $'Enhanced release:\n\n- Performance improvements\n- Bug fixes' +``` + `output_products` is optional. When omitted, the bundle products array is derived from the matched changelog files' own `products` fields — the same fallback used by all other profile types. Set `output_products` when you want a single clean product entry that reflects the release identity rather than the diverse metadata across individual changelog files, or to hardcode a lifecycle that cannot be inferred from the tag format: ```yaml diff --git a/docs/syntax/changelog.md b/docs/syntax/changelog.md index a3086b735..dfada1db0 100644 --- a/docs/syntax/changelog.md +++ b/docs/syntax/changelog.md @@ -142,10 +142,14 @@ 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 hide-features +# Example bundle with description and hide-features products: - product: elasticsearch target: 9.3.0 +description: | + This release includes new features and bug fixes. + + For more information, see the [release notes](https://example.com/docs). hide-features: - feature:hidden-api - feature:experimental @@ -223,10 +227,15 @@ The version is extracted from the first product's `target` field in each bundle ## Rendered output -Each bundle renders as a `## {version}` section with subsections beneath: +Each bundle renders as a `## {version}` section with optional description and subsections beneath: ```markdown ## 0.100.0 + +This release includes new features and bug fixes. + +Download the release binaries: https://github.com/elastic/elasticsearch/releases/tag/v0.100.0 + ### Features and enhancements ... ### Fixes @@ -237,6 +246,8 @@ Each bundle renders as a `## {version}` section with subsections beneath: ... ``` +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. + ### Section types | Section | Entry type | Rendering | diff --git a/src/Elastic.Documentation.Configuration/Changelog/BundleConfiguration.cs b/src/Elastic.Documentation.Configuration/Changelog/BundleConfiguration.cs index 7f3717d82..5282420b3 100644 --- a/src/Elastic.Documentation.Configuration/Changelog/BundleConfiguration.cs +++ b/src/Elastic.Documentation.Configuration/Changelog/BundleConfiguration.cs @@ -27,6 +27,12 @@ public record BundleConfiguration /// public bool Resolve { get; init; } = true; + /// + /// Default bundle description used when no profile-specific description is provided. + /// Supports {version}, {lifecycle}, {owner}, and {repo} placeholders. + /// + public string? Description { get; init; } + /// /// Default GitHub repository name applied to all profiles that do not specify their own. /// Used for generating correct PR/issue links when the product ID differs from the repo name. @@ -81,6 +87,12 @@ public record BundleProfile /// public string? OutputProducts { get; init; } + /// + /// Profile-specific bundle description. When provided, overrides the bundle.description default. + /// Supports {version}, {lifecycle}, {owner}, and {repo} placeholders. + /// + public string? Description { get; init; } + /// /// GitHub repository name stored on each product in the bundle output. /// Used for generating correct PR/issue links when the product ID differs from the repo name. diff --git a/src/Elastic.Documentation.Configuration/ReleaseNotes/Bundle.cs b/src/Elastic.Documentation.Configuration/ReleaseNotes/Bundle.cs index 3e6f48fca..a417ceabe 100644 --- a/src/Elastic.Documentation.Configuration/ReleaseNotes/Bundle.cs +++ b/src/Elastic.Documentation.Configuration/ReleaseNotes/Bundle.cs @@ -14,6 +14,10 @@ public sealed record BundleDto { public List? Products { get; set; } /// + /// Optional introductory description text for this bundle. + /// + public string? Description { 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 444e8ffc9..7e38f489f 100644 --- a/src/Elastic.Documentation.Configuration/ReleaseNotes/BundleLoader.cs +++ b/src/Elastic.Documentation.Configuration/ReleaseNotes/BundleLoader.cs @@ -217,11 +217,27 @@ private static LoadedBundle MergeBundleGroup(IGrouping gro // Use the first bundle's metadata as the base var first = bundlesList[0]; + // Merge descriptions from all bundles + var descriptions = bundlesList + .Select(b => b.Data?.Description) + .Where(d => !string.IsNullOrEmpty(d)) + .ToList(); + + var mergedDescription = descriptions.Count switch + { + 0 => null, + 1 => descriptions[0], + _ => string.Join("\n\n", descriptions) + }; + + // Create merged bundle data with combined description + var mergedData = first.Data with { Description = mergedDescription }; + return new LoadedBundle( first.Version, combinedRepo, first.Owner, - first.Data, + mergedData, first.FilePath, mergedEntries ); diff --git a/src/Elastic.Documentation.Configuration/ReleaseNotes/ReleaseNotesSerialization.cs b/src/Elastic.Documentation.Configuration/ReleaseNotes/ReleaseNotesSerialization.cs index d861b383b..671c2d510 100644 --- a/src/Elastic.Documentation.Configuration/ReleaseNotes/ReleaseNotesSerialization.cs +++ b/src/Elastic.Documentation.Configuration/ReleaseNotes/ReleaseNotesSerialization.cs @@ -135,6 +135,7 @@ public static string SerializeBundle(Bundle bundle) private static Bundle ToBundle(BundleDto dto) => new() { Products = dto.Products?.Select(ToBundledProduct).ToList() ?? [], + Description = dto.Description, HideFeatures = dto.HideFeatures ?? [], Entries = dto.Entries?.Select(ToBundledEntry).ToList() ?? [] }; @@ -239,6 +240,7 @@ private static ChangelogEntryType ParseEntryType(string? value) private static BundleDto ToDto(Bundle bundle) => new() { Products = bundle.Products.Select(ToDto).ToList(), + Description = bundle.Description, 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 060d6ef71..be5bf36ba 100644 --- a/src/Elastic.Documentation/ReleaseNotes/Bundle.cs +++ b/src/Elastic.Documentation/ReleaseNotes/Bundle.cs @@ -13,6 +13,12 @@ public record Bundle /// Products included in this bundle. public IReadOnlyList Products { get; init; } = []; + /// + /// Optional introductory description text for this bundle. + /// Rendered as introductory content after the release heading. + /// + public string? Description { 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 83be2c41e..cd055e9d7 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); + return GenerateMarkdown(displayVersion, titleSlug, bundle.Repo, bundle.Owner, entriesByType, subsections, hideLinks, typeFilter, publishBlocker, bundle.Data?.Description); } /// @@ -152,7 +152,8 @@ private static string GenerateMarkdown( bool subsections, bool hideLinks, ChangelogTypeFilter typeFilter, - PublishBlocker? publishBlocker) + PublishBlocker? publishBlocker, + string? description = null) { var sb = new StringBuilder(); @@ -176,6 +177,13 @@ private static string GenerateMarkdown( _ = sb.AppendLine(CultureInfo.InvariantCulture, $"## {title}"); + // Add description if present + if (!string.IsNullOrEmpty(description)) + { + _ = sb.AppendLine(); + _ = sb.AppendLine(description); + } + // Check if we have any content at all var hasAnyContent = features.Count > 0 || enhancements.Count > 0 || security.Count > 0 || bugFixes.Count > 0 || docs.Count > 0 || regressions.Count > 0 || other.Count > 0 || diff --git a/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs b/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs index 5316cf208..ce939bb77 100644 --- a/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs +++ b/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs @@ -9,6 +9,7 @@ using Elastic.Changelog.Configuration; using Elastic.Changelog.GitHub; using Elastic.Changelog.Rendering; +using Elastic.Documentation; using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Assembler; using Elastic.Documentation.Configuration.Changelog; @@ -22,6 +23,27 @@ namespace Elastic.Changelog.Bundling; +/// +/// Helper for performing placeholder substitution in bundle descriptions +/// +internal static class BundleDescriptionSubstitution +{ + /// + /// Substitutes placeholders in a description string with provided values + /// + public static string SubstitutePlaceholders(string description, string? version, string? lifecycle, string? owner, string? repo) + { + if (string.IsNullOrEmpty(description)) + return description; + + return description + .Replace("{version}", version ?? string.Empty) + .Replace("{lifecycle}", lifecycle ?? string.Empty) + .Replace("{owner}", owner ?? string.Empty) + .Replace("{repo}", repo ?? string.Empty); + } +} + /// /// Arguments for the BundleChangelogs method /// @@ -84,6 +106,12 @@ public record BundleChangelogsArguments /// public string[]? HideFeatures { get; init; } + /// + /// Optional bundle description with placeholder substitution. + /// Supports {version}, {lifecycle}, {owner}, and {repo} placeholders. + /// + public string? Description { get; init; } + /// /// When non-null (including empty), PR/issue links are filtered to this owner/repo allowlist (from changelog.yml bundle.link_allow_repos). /// @@ -321,6 +349,23 @@ public async Task BundleChangelogs(IDiagnosticsCollector collector, Bundle } } + // Apply description with placeholder substitution + if (!string.IsNullOrEmpty(input.Description)) + { + // Get version/lifecycle from the first output product or first product in the bundle + var version = (input.OutputProducts?.Count > 0 ? input.OutputProducts[0].Target : null) + ?? (bundleData.Products.Count > 0 ? bundleData.Products[0].Target : null); + var lifecycle = (input.OutputProducts?.Count > 0 ? input.OutputProducts[0].Lifecycle : null) + ?? (bundleData.Products.Count > 0 ? bundleData.Products[0].Lifecycle?.ToStringFast(true) : null); + var owner = input.Owner ?? "elastic"; + var repo = input.Repo ?? (bundleData.Products.Count > 0 ? bundleData.Products[0].ProductId : null) ?? "unknown"; + + var substitutedDescription = BundleDescriptionSubstitution.SubstitutePlaceholders( + input.Description, version, lifecycle, owner, repo); + + bundleData = bundleData with { Description = substitutedDescription }; + } + // Write bundle file await WriteBundleFileAsync(bundleData, outputPath, ctx); @@ -355,12 +400,13 @@ public async Task BundleChangelogs(IDiagnosticsCollector collector, Bundle if (filterResult == null) return null; - // Resolve bundle-specific output path, output products, repo, owner, and hide-features from profile + // Resolve bundle-specific output path, output products, repo, owner, hide-features, and description from profile string? outputPath = null; IReadOnlyList? outputProducts = null; string? repo = null; string? owner = null; string[]? mergedHideFeatures = null; + string? profileDescription = null; if (config?.Bundle?.Profiles != null && config.Bundle.Profiles.TryGetValue(input.Profile!, out var profile)) { @@ -402,6 +448,26 @@ public async Task BundleChangelogs(IDiagnosticsCollector collector, Bundle repo = profile.Repo ?? config.Bundle.Repo; owner = profile.Owner ?? config.Bundle.Owner; mergedHideFeatures = profile.HideFeatures?.Count > 0 ? [.. profile.HideFeatures] : null; + + // Handle profile-specific description with placeholder substitution + var descriptionTemplate = profile.Description ?? config.Bundle.Description; + if (!string.IsNullOrEmpty(descriptionTemplate)) + { + // Validate placeholder usage in profile mode + var hasVersionPlaceholder = descriptionTemplate.Contains("{version}") || descriptionTemplate.Contains("{lifecycle}"); + if (hasVersionPlaceholder && + filterResult.Version == "unknown" && + string.IsNullOrEmpty(profile.OutputProducts)) + { + collector.EmitError(string.Empty, + $"Profile '{input.Profile}' uses placeholders in description but no version is available for substitution. " + + "Either provide a version argument, or add 'output_products' pattern to the profile configuration."); + return null; + } + + profileDescription = BundleDescriptionSubstitution.SubstitutePlaceholders( + descriptionTemplate, filterResult.Version, resolvedLifecycle, owner, repo); + } } return input with @@ -414,7 +480,8 @@ public async Task BundleChangelogs(IDiagnosticsCollector collector, Bundle OutputProducts = outputProducts, Repo = repo, Owner = owner, - HideFeatures = mergedHideFeatures + HideFeatures = mergedHideFeatures, + Description = profileDescription }; } @@ -438,6 +505,9 @@ private BundleChangelogsArguments ApplyConfigDefaults(BundleChangelogsArguments var repo = input.Repo ?? config.Bundle.Repo; var owner = input.Owner ?? config.Bundle.Owner; + // Apply description: CLI takes precedence; fall back to bundle-level config default + var description = input.Description ?? config.Bundle.Description; + return input with { Directory = directory, @@ -445,6 +515,7 @@ private BundleChangelogsArguments ApplyConfigDefaults(BundleChangelogsArguments Resolve = resolve, Repo = repo, Owner = owner, + Description = description, LinkAllowRepos = config.Bundle.LinkAllowRepos }; } diff --git a/src/services/Elastic.Changelog/Configuration/ChangelogConfigurationLoader.cs b/src/services/Elastic.Changelog/Configuration/ChangelogConfigurationLoader.cs index 797b7371c..f07b82a80 100644 --- a/src/services/Elastic.Changelog/Configuration/ChangelogConfigurationLoader.cs +++ b/src/services/Elastic.Changelog/Configuration/ChangelogConfigurationLoader.cs @@ -517,6 +517,7 @@ private static PivotConfiguration ConvertPivot(PivotConfigurationYaml yamlPivot) Products = kvp.Value.Products, Output = kvp.Value.Output, OutputProducts = kvp.Value.OutputProducts, + Description = kvp.Value.Description, Repo = kvp.Value.Repo, Owner = kvp.Value.Owner, HideFeatures = kvp.Value.HideFeatures?.Values, @@ -529,6 +530,7 @@ private static PivotConfiguration ConvertPivot(PivotConfigurationYaml yamlPivot) Directory = yaml.Directory, OutputDirectory = yaml.OutputDirectory, Resolve = yaml.Resolve ?? true, + Description = yaml.Description, Repo = yaml.Repo, Owner = yaml.Owner, LinkAllowRepos = linkAllowRepos, diff --git a/src/services/Elastic.Changelog/GithubRelease/GitHubReleaseChangelogService.cs b/src/services/Elastic.Changelog/GithubRelease/GitHubReleaseChangelogService.cs index 95d98324f..97344fc15 100644 --- a/src/services/Elastic.Changelog/GithubRelease/GitHubReleaseChangelogService.cs +++ b/src/services/Elastic.Changelog/GithubRelease/GitHubReleaseChangelogService.cs @@ -54,6 +54,12 @@ public record CreateChangelogsFromReleaseArguments /// public bool WarnOnTypeMismatch { get; init; } = true; + /// + /// Optional bundle description text with placeholder support. + /// Supports {version}, {lifecycle}, {owner}, and {repo} placeholders. + /// + public string? Description { 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. @@ -181,7 +187,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.Config, ctx); + var bundlePath = await CreateBundleViaService(collector, outputDir, createdFiles, productInfo, owner, repo, input, ctx); if (bundlePath != null) _logger.LogInformation("Created bundle file: {BundlePath}", bundlePath); } @@ -306,7 +312,7 @@ private static string GenerateYaml(ChangelogEntry data) => ProductArgument productInfo, string owner, string repo, - string? configPath, + CreateChangelogsFromReleaseArguments input, Cancel ctx) { // Build the bundles subfolder path (mirrors the previous CreateBundleFile convention) @@ -334,8 +340,9 @@ private static string GenerateYaml(ChangelogEntry data) => Prs = prUrls, Owner = owner, Repo = repo, - Config = configPath, - OutputProducts = [productInfo] + Config = input.Config, + OutputProducts = [productInfo], + Description = input.Description }; 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 45856b7b5..e7a817c6e 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 description if present + if (!string.IsNullOrEmpty(context.Description)) + { + _ = sb.AppendLine(context.Description); + _ = sb.AppendLine(); + } + // Group entries by type var entriesByType = context.EntriesByType; var security = entriesByType.GetValueOrDefault(Security, []); diff --git a/src/services/Elastic.Changelog/Rendering/ChangelogRenderContext.cs b/src/services/Elastic.Changelog/Rendering/ChangelogRenderContext.cs index 0e61ad942..e77f93401 100644 --- a/src/services/Elastic.Changelog/Rendering/ChangelogRenderContext.cs +++ b/src/services/Elastic.Changelog/Rendering/ChangelogRenderContext.cs @@ -26,4 +26,8 @@ public record ChangelogRenderContext public required Dictionary EntryToOwner { get; init; } public required Dictionary EntryToHideLinks { get; init; } public ChangelogConfiguration? Configuration { get; init; } + /// + /// Optional description for the changelog. Only set when there's a single bundle with a description (MVP approach). + /// + public string? Description { get; init; } } diff --git a/src/services/Elastic.Changelog/Rendering/ChangelogRenderingService.cs b/src/services/Elastic.Changelog/Rendering/ChangelogRenderingService.cs index 099ea5587..49b1e65ba 100644 --- a/src/services/Elastic.Changelog/Rendering/ChangelogRenderingService.cs +++ b/src/services/Elastic.Changelog/Rendering/ChangelogRenderingService.cs @@ -133,8 +133,27 @@ Cancel ctx // Emit warnings for hidden entries EmitHiddenEntryWarnings(collector, resolvedResult.Entries, combinedHideFeatures); + // Extract descriptions from bundles for MVP support + var bundleDescriptions = validationResult.Bundles + .Select(b => b.Data.Description) + .Where(d => !string.IsNullOrEmpty(d)) + .ToList(); + + // MVP: Check for multiple descriptions and warn + string? renderDescription = null; + if (bundleDescriptions.Count > 1) + { + collector.EmitWarning(string.Empty, + $"Multiple bundles contain descriptions ({bundleDescriptions.Count} found). " + + "Multi-bundle description support is not yet implemented. Descriptions will be skipped."); + } + else if (bundleDescriptions.Count == 1) + { + renderDescription = bundleDescriptions[0]; + } + // Build render context - var context = BuildRenderContext(input, outputSetup, resolvedResult, combinedHideFeatures, config); + var context = BuildRenderContext(input, outputSetup, resolvedResult, combinedHideFeatures, config, renderDescription); // Validate entry types if (!ValidateEntryTypes(collector, resolvedResult.Entries, config.Types)) @@ -246,7 +265,8 @@ private static ChangelogRenderContext BuildRenderContext( OutputSetup outputSetup, ResolvedEntriesResult resolved, HashSet featureIdsToHide, - ChangelogConfiguration? config) + ChangelogConfiguration? config, + string? description = null) { // Group entries by type var entriesByType = resolved.Entries @@ -287,7 +307,8 @@ private static ChangelogRenderContext BuildRenderContext( EntryToRepo = entryToRepo, EntryToOwner = entryToOwner, EntryToHideLinks = entryToHideLinks, - Configuration = config + Configuration = config, + Description = description }; } diff --git a/src/services/Elastic.Changelog/Rendering/Markdown/IndexMarkdownRenderer.cs b/src/services/Elastic.Changelog/Rendering/Markdown/IndexMarkdownRenderer.cs index e00af54ca..50c23dfe6 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 description if present + if (!string.IsNullOrEmpty(context.Description)) + { + _ = sb.AppendLine(); + _ = sb.AppendLine(context.Description); + } + if (otherLinks.Count > 0) { var linksText = string.Join(" and ", otherLinks); diff --git a/src/services/Elastic.Changelog/Serialization/ChangelogConfigurationYaml.cs b/src/services/Elastic.Changelog/Serialization/ChangelogConfigurationYaml.cs index f74818c6a..372be436f 100644 --- a/src/services/Elastic.Changelog/Serialization/ChangelogConfigurationYaml.cs +++ b/src/services/Elastic.Changelog/Serialization/ChangelogConfigurationYaml.cs @@ -280,6 +280,11 @@ internal record BundleConfigurationYaml /// public bool? Resolve { get; set; } + /// + /// Default bundle description used when no profile-specific description is provided. + /// + public string? Description { get; set; } + /// /// Default GitHub repository name applied to all profiles that do not specify their own. /// @@ -324,6 +329,11 @@ internal record BundleProfileYaml /// public string? OutputProducts { get; set; } + /// + /// Profile-specific bundle description. Overrides bundle.description when provided. + /// + public string? Description { get; set; } + /// /// GitHub repository name for generating PR/issue links in bundle output. /// diff --git a/src/tooling/docs-builder/Commands/ChangelogCommand.cs b/src/tooling/docs-builder/Commands/ChangelogCommand.cs index 389b84922..1e801b388 100644 --- a/src/tooling/docs-builder/Commands/ChangelogCommand.cs +++ b/src/tooling/docs-builder/Commands/ChangelogCommand.cs @@ -490,6 +490,7 @@ async static (s, collector, state, ctx) => await s.CreateChangelog(collector, st /// Include all changelogs in the directory. /// Optional: Path to the changelog.yml configuration file. Defaults to 'docs/changelog.yml' /// 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). /// 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. @@ -512,6 +513,7 @@ public async Task Bundle( bool all = false, string? config = null, string? directory = null, + string? description = null, string[]? hideFeatures = null, [ProductInfoParser] List? inputProducts = null, string? output = null, @@ -767,7 +769,8 @@ public async Task Bundle( Output = processedOutput, Profile = profile, ProfileArgument = profileArg, - Config = config + Config = config, + Description = description }; var planResult = await service.PlanBundleAsync(collector, planInput, releaseVersion != null, ctx); if (planResult == null) @@ -802,9 +805,20 @@ public async Task Bundle( ProfileReport = isProfileMode ? profileReport : null, Report = !isProfileMode ? report : null, Config = config, - HideFeatures = allFeatureIdsForBundle.Count > 0 ? allFeatureIdsForBundle.ToArray() : null + HideFeatures = allFeatureIdsForBundle.Count > 0 ? allFeatureIdsForBundle.ToArray() : null, + Description = description }; + // Validate placeholder usage in option-based mode + if (!isProfileMode && description != null && + (description.Contains("{version}") || description.Contains("{lifecycle}") || description.Contains("{owner}") || description.Contains("{repo}")) && + outputProducts == null) + { + collector.EmitError(string.Empty, + "When using placeholders in --description in option-based mode, --output-products must be explicitly specified to ensure predictable substitution values."); + return 1; + } + serviceInvoker.AddCommand(service, input, async static (s, collector, state, ctx) => await s.BundleChangelogs(collector, state, ctx) ); @@ -1113,6 +1127,7 @@ async static (s, collector, state, ctx) => await s.RenderChangelogs(collector, s /// Required: GitHub repository in owner/repo format (e.g., "elastic/elasticsearch" or just "elasticsearch" which defaults to elastic/elasticsearch) /// Optional: Version tag to fetch (e.g., "v9.0.0", "9.0.0"). Defaults to "latest" /// 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: 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 @@ -1122,6 +1137,7 @@ public async Task GitHubRelease( [Argument] string repo, [Argument] string version = "latest", string? config = null, + string? description = null, string? output = null, bool stripTitlePrefix = false, bool warnOnTypeMismatch = true, @@ -1149,7 +1165,8 @@ public async Task GitHubRelease( Config = config, Output = resolvedOutput, StripTitlePrefix = stripTitlePrefixResolved, - WarnOnTypeMismatch = warnOnTypeMismatch + WarnOnTypeMismatch = warnOnTypeMismatch, + Description = description }; serviceInvoker.AddCommand(service, input, diff --git a/tests/Elastic.Changelog.Tests/Changelogs/BundleLoading/BundleLoaderTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/BundleLoading/BundleLoaderTests.cs index c24670546..12d893479 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/BundleLoading/BundleLoaderTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/BundleLoading/BundleLoaderTests.cs @@ -1090,6 +1090,95 @@ public void LoadBundles_HideFeaturesSerializesAndDeserializesCorrectly() _warnings.Should().BeEmpty(); } + [Fact] + public void LoadBundles_DescriptionSerializesAndDeserializesCorrectly() + { + // Arrange - Test round-trip serialization of description field + var bundlesFolder = "/docs/changelog/bundles"; + _fileSystem.Directory.CreateDirectory(bundlesFolder); + + var multilineDescription = """ + This is a test description with multiple paragraphs. + + It includes: + - A bullet list + - Multiple lines + - And a [link](https://example.com) for testing + + This ensures proper YAML serialization and deserialization. + """; + + var originalBundle = new Bundle + { + Products = + [ + new BundledProduct { ProductId = "elasticsearch", Target = "9.3.0" } + ], + Description = multilineDescription, + 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.Description.Should().Be(multilineDescription); + _warnings.Should().BeEmpty(); + } + + [Fact] + public void LoadBundles_DescriptionCanBeNull() + { + // Arrange - Test that null description 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" } + ], + Description = 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.Description.Should().BeNull(); + _warnings.Should().BeEmpty(); + } + [Fact] public void LoadedBundle_HideFeatures_ExposedFromBundleData() { From 013f543b91d05e1954be07391d293eeded03f58f Mon Sep 17 00:00:00 2001 From: lcawl Date: Wed, 8 Apr 2026 14:43:58 -0700 Subject: [PATCH 2/7] Fixed Cross-Platform Line Ending Issue --- .../Changelogs/BundleLoading/BundleLoaderTests.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/Elastic.Changelog.Tests/Changelogs/BundleLoading/BundleLoaderTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/BundleLoading/BundleLoaderTests.cs index 12d893479..3887682e1 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/BundleLoading/BundleLoaderTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/BundleLoading/BundleLoaderTests.cs @@ -1136,7 +1136,12 @@ This ensures proper YAML serialization and deserialization. // Assert bundles.Should().HaveCount(1); - bundles[0].Data.Description.Should().Be(multilineDescription); + + // Normalize line endings for cross-platform compatibility + var actualDescription = bundles[0].Data.Description?.Replace("\r\n", "\n").Replace("\r", "\n"); + var expectedDescription = multilineDescription.Replace("\r\n", "\n").Replace("\r", "\n"); + actualDescription.Should().Be(expectedDescription); + _warnings.Should().BeEmpty(); } From 96b506f9754c606359ef439d20703d352423065d Mon Sep 17 00:00:00 2001 From: lcawl Date: Wed, 8 Apr 2026 16:40:49 -0700 Subject: [PATCH 3/7] Fix examples --- docs/cli/changelog/bundle.md | 13 +++++++------ docs/contribute/changelog.md | 2 +- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/docs/cli/changelog/bundle.md b/docs/cli/changelog/bundle.md index 6250f9408..2a62695af 100644 --- a/docs/cli/changelog/bundle.md +++ b/docs/cli/changelog/bundle.md @@ -376,7 +376,7 @@ For multiline descriptions with multiple paragraphs, lists, and links, use ANSI- ```sh docs-builder changelog bundle \ --all \ - --description $'This release includes significant improvements:\n\n- Enhanced performance\n- Bug fixes and stability improvements\n\nFor more information, see the [release notes](https://example.com/docs).' + --description $'This release includes significant improvements:\n\n- Enhanced performance\n- Bug fixes and stability improvements\n\nFor security updates, go to [security announcements](https://example.com/docs).' ``` When using placeholders in option-based mode, you must explicitly specify `--output-products` for predictable substitution: @@ -490,11 +490,12 @@ docs-builder changelog bundle elasticsearch-release ./report.html To fix the second case, either provide a version argument or add an `output_products` pattern to your profile: ```yaml -profiles: - elasticsearch-release: - products: "elasticsearch * *" - output_products: "elasticsearch {version}" # Enables placeholder substitution - description: "Download: https://github.com/{owner}/{repo}/releases/tag/v{version}" +bundle: + profiles: + elasticsearch-release: + products: "elasticsearch * *" + output_products: "elasticsearch {version}" # Enables placeholder substitution + description: "Download: https://github.com/{owner}/{repo}/releases/tag/v{version}" ``` ::: diff --git a/docs/contribute/changelog.md b/docs/contribute/changelog.md index a7b3017de..0bc27acfa 100644 --- a/docs/contribute/changelog.md +++ b/docs/contribute/changelog.md @@ -848,7 +848,7 @@ bundle: - Bug fixes and stability improvements - New features for better user experience - For more information, see the [release notes](https://example.com/docs/release-notes). + For security updates, go to [security announcements](https://example.com/docs). Download the release binaries: https://github.com/{owner}/{repo}/releases/tag/v{version} ``` From b581783a8c8e5a7f216119940d96b2b9a9c7e5ef Mon Sep 17 00:00:00 2001 From: lcawl Date: Thu, 9 Apr 2026 08:41:34 -0700 Subject: [PATCH 4/7] Fix placeholder validation --- .../Bundling/ChangelogBundlingService.cs | 73 ++++++++++++- .../docs-builder/Commands/ChangelogCommand.cs | 10 -- .../Changelogs/BundleChangelogsTests.cs | 100 ++++++++++++++++++ 3 files changed, 171 insertions(+), 12 deletions(-) diff --git a/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs b/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs index ce939bb77..600f32881 100644 --- a/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs +++ b/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs @@ -31,11 +31,41 @@ internal static class BundleDescriptionSubstitution /// /// Substitutes placeholders in a description string with provided values /// - public static string SubstitutePlaceholders(string description, string? version, string? lifecycle, string? owner, string? repo) + public static string SubstitutePlaceholders(string description, string? version, string? lifecycle, string? owner, string? repo) => + SubstitutePlaceholders(description, version, lifecycle, owner, repo, validateResolvable: false); + + /// + /// Substitutes placeholders in a description string with provided values + /// + /// The description string containing placeholders + /// Version value for {version} placeholder + /// Lifecycle value for {lifecycle} placeholder + /// Owner value for {owner} placeholder + /// Repository value for {repo} placeholder + /// If true, validates that all used placeholders can be resolved + /// Description with placeholders substituted + /// When validateResolvable is true and placeholders cannot be resolved + public static string SubstitutePlaceholders(string description, string? version, string? lifecycle, string? owner, string? repo, bool validateResolvable) { if (string.IsNullOrEmpty(description)) return description; + if (validateResolvable) + { + var missingValues = new List(); + if (description.Contains("{version}") && string.IsNullOrEmpty(version)) + missingValues.Add("version"); + if (description.Contains("{lifecycle}") && string.IsNullOrEmpty(lifecycle)) + missingValues.Add("lifecycle"); + if (description.Contains("{owner}") && string.IsNullOrEmpty(owner)) + missingValues.Add("owner"); + if (description.Contains("{repo}") && string.IsNullOrEmpty(repo)) + missingValues.Add("repo"); + + if (missingValues.Count > 0) + throw new InvalidOperationException($"Cannot resolve placeholders: {string.Join(", ", missingValues)}"); + } + return description .Replace("{version}", version ?? string.Empty) .Replace("{lifecycle}", lifecycle ?? string.Empty) @@ -205,6 +235,9 @@ public async Task BundleChangelogs(IDiagnosticsCollector collector, Bundle if (!ValidateInput(collector, input)) return false; + if (!ValidatePlaceholderUsage(collector, input)) + return false; + if (!ValidateLinkAllowlist(collector, input)) return false; @@ -455,16 +488,27 @@ public async Task BundleChangelogs(IDiagnosticsCollector collector, Bundle { // Validate placeholder usage in profile mode var hasVersionPlaceholder = descriptionTemplate.Contains("{version}") || descriptionTemplate.Contains("{lifecycle}"); + var hasOwnerRepoPlaceholder = descriptionTemplate.Contains("{owner}") || descriptionTemplate.Contains("{repo}"); + if (hasVersionPlaceholder && filterResult.Version == "unknown" && string.IsNullOrEmpty(profile.OutputProducts)) { collector.EmitError(string.Empty, - $"Profile '{input.Profile}' uses placeholders in description but no version is available for substitution. " + + $"Profile '{input.Profile}' uses {{version}} or {{lifecycle}} placeholders in description but no version is available for substitution. " + "Either provide a version argument, or add 'output_products' pattern to the profile configuration."); return null; } + if (hasOwnerRepoPlaceholder && + (string.IsNullOrEmpty(owner) || string.IsNullOrEmpty(repo))) + { + collector.EmitError(string.Empty, + $"Profile '{input.Profile}' uses {{owner}} or {{repo}} placeholders in description but values are not resolvable. " + + "Ensure repository metadata is available in the configuration."); + return null; + } + profileDescription = BundleDescriptionSubstitution.SubstitutePlaceholders( descriptionTemplate, filterResult.Version, resolvedLifecycle, owner, repo); } @@ -628,6 +672,31 @@ private bool ValidateInput(IDiagnosticsCollector collector, BundleChangelogsArgu return true; } + private static bool ValidatePlaceholderUsage(IDiagnosticsCollector collector, BundleChangelogsArguments input) + { + // Only validate in option-based mode (profile mode has separate validation) + if (!string.IsNullOrEmpty(input.Profile)) + return true; + + if (string.IsNullOrEmpty(input.Description)) + return true; + + var hasPlaceholders = input.Description.Contains("{version}") || + input.Description.Contains("{lifecycle}") || + input.Description.Contains("{owner}") || + input.Description.Contains("{repo}"); + + if (hasPlaceholders && (input.OutputProducts == null || input.OutputProducts.Count == 0)) + { + collector.EmitError(string.Empty, + "When using placeholders in bundle description in option-based mode, " + + "--output-products must be explicitly specified to ensure predictable substitution values."); + return false; + } + + return true; + } + private static bool ValidateLinkAllowlist(IDiagnosticsCollector collector, BundleChangelogsArguments input) { if (input.LinkAllowRepos == null) diff --git a/src/tooling/docs-builder/Commands/ChangelogCommand.cs b/src/tooling/docs-builder/Commands/ChangelogCommand.cs index 1e801b388..873fcfaf5 100644 --- a/src/tooling/docs-builder/Commands/ChangelogCommand.cs +++ b/src/tooling/docs-builder/Commands/ChangelogCommand.cs @@ -809,16 +809,6 @@ public async Task Bundle( Description = description }; - // Validate placeholder usage in option-based mode - if (!isProfileMode && description != null && - (description.Contains("{version}") || description.Contains("{lifecycle}") || description.Contains("{owner}") || description.Contains("{repo}")) && - outputProducts == null) - { - collector.EmitError(string.Empty, - "When using placeholders in --description in option-based mode, --output-products must be explicitly specified to ensure predictable substitution values."); - return 1; - } - serviceInvoker.AddCommand(service, input, async static (s, collector, state, ctx) => await s.BundleChangelogs(collector, state, ctx) ); diff --git a/tests/Elastic.Changelog.Tests/Changelogs/BundleChangelogsTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/BundleChangelogsTests.cs index d508f5271..1556027da 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/BundleChangelogsTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/BundleChangelogsTests.cs @@ -7,6 +7,7 @@ using Elastic.Changelog.Bundling; using Elastic.Documentation.Configuration; using Elastic.Documentation.Diagnostics; +using Microsoft.Extensions.Logging.Abstractions; namespace Elastic.Changelog.Tests.Changelogs; @@ -5959,4 +5960,103 @@ public async Task BundleChangelogs_PartialPerProductRules_AllOrNothingReplacemen var bundleContent = await FileSystem.File.ReadAllTextAsync(outputPath, TestContext.Current.CancellationToken); bundleContent.Should().Contain("1755268204-partial-rule.yaml", "entry should be included - per-product rule ignores global type exclusions"); } + + [Fact] + public async Task BundleChangelogs_OptionModeWithPlaceholdersButNoOutputProducts_ReturnsError() + { + // Arrange + CreateSampleChangelogs(); + var input = new BundleChangelogsArguments + { + Directory = _changelogDir, + All = true, + Output = "bundle.yaml", + Description = "Release includes {version} with {lifecycle} features from {owner}/{repo}" // Has placeholders but no --output-products + }; + + // Act + var result = await Service.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeFalse("bundling should fail when placeholders are used without --output-products"); + Collector.Errors.Should().Be(1, "should have exactly one validation error"); + Collector.Diagnostics.Should().Contain(d => d.Message.Contains( + "When using placeholders in bundle description in option-based mode, --output-products must be explicitly specified to ensure predictable substitution values.")); + } + + [Fact] + public async Task BundleChangelogs_OptionModeWithPlaceholdersAndOutputProducts_Succeeds() + { + // Arrange + CreateSampleChangelogs(); + var outputProducts = new List + { + new() { Product = "elasticsearch", Target = "9.2.0", Lifecycle = "ga" } + }; + + var input = new BundleChangelogsArguments + { + Directory = _changelogDir, + All = true, + Output = "bundle.yaml", + OutputProducts = outputProducts, + Description = "Release includes {version} with {lifecycle} features from {owner}/{repo}", + Owner = "elastic", + Repo = "elasticsearch" + }; + + // Act + var result = await Service.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeTrue("bundling should succeed when placeholders have --output-products"); + Collector.Errors.Should().Be(0, "no errors expected when validation passes"); + + var bundleContent = await FileSystem.File.ReadAllTextAsync("bundle.yaml", TestContext.Current.CancellationToken); + bundleContent.Should().Contain("Release includes 9.2.0 with ga features from elastic/elasticsearch", + "placeholders should be substituted correctly"); + } + + [Fact] + public async Task BundleChangelogs_OptionModeWithConfigDescriptionAndPlaceholders_ReturnsError() + { + // Arrange - test validation with description that could come from config (simulated via CLI) + CreateSampleChangelogs(); + var input = new BundleChangelogsArguments + { + Directory = _changelogDir, + All = true, + Output = "bundle.yaml", + Description = "Version {version} includes {lifecycle} updates from {owner}/{repo}" // Simulate config-provided description + // No OutputProducts - should fail validation + }; + + // Act + var result = await Service.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeFalse("bundling should fail when description has placeholders without --output-products"); + Collector.Errors.Should().Be(1, "should have exactly one validation error"); + Collector.Diagnostics.Should().Contain(d => d.Message.Contains( + "When using placeholders in bundle description in option-based mode, --output-products must be explicitly specified to ensure predictable substitution values.")); + } + + + private void CreateSampleChangelogs() + { + // language=yaml + var changelog1 = + """ + title: First changelog + type: feature + products: + - product: elasticsearch + target: 9.2.0 + lifecycle: ga + prs: + - https://github.com/elastic/elasticsearch/pull/100 + """; + + FileSystem.File.WriteAllText(FileSystem.Path.Join(_changelogDir, "changelog1.yaml"), changelog1); + } } From fbb2a6780613c972d896c06927e03b3a9dd296c6 Mon Sep 17 00:00:00 2001 From: lcawl Date: Thu, 9 Apr 2026 09:50:35 -0700 Subject: [PATCH 5/7] Fix test contamination --- .../Changelogs/BundleChangelogsTests.cs | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/tests/Elastic.Changelog.Tests/Changelogs/BundleChangelogsTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/BundleChangelogsTests.cs index 1556027da..52b915966 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/BundleChangelogsTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/BundleChangelogsTests.cs @@ -3971,7 +3971,7 @@ await FileSystem.File.WriteAllTextAsync(changelogFile, { Directory = _changelogDir, Prs = [prsFile], - Output = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, "bundle.yaml") + Output = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml") }; // Act @@ -4012,7 +4012,7 @@ await FileSystem.File.WriteAllTextAsync(changelogFile, { Directory = _changelogDir, Issues = [issuesFile], - Output = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, "bundle.yaml") + Output = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml") }; // Act @@ -5966,11 +5966,12 @@ public async Task BundleChangelogs_OptionModeWithPlaceholdersButNoOutputProducts { // Arrange CreateSampleChangelogs(); + var outputPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml"); var input = new BundleChangelogsArguments { Directory = _changelogDir, All = true, - Output = "bundle.yaml", + Output = outputPath, Description = "Release includes {version} with {lifecycle} features from {owner}/{repo}" // Has placeholders but no --output-products }; @@ -5994,11 +5995,12 @@ public async Task BundleChangelogs_OptionModeWithPlaceholdersAndOutputProducts_S new() { Product = "elasticsearch", Target = "9.2.0", Lifecycle = "ga" } }; + var outputPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml"); var input = new BundleChangelogsArguments { Directory = _changelogDir, All = true, - Output = "bundle.yaml", + Output = outputPath, OutputProducts = outputProducts, Description = "Release includes {version} with {lifecycle} features from {owner}/{repo}", Owner = "elastic", @@ -6012,7 +6014,7 @@ public async Task BundleChangelogs_OptionModeWithPlaceholdersAndOutputProducts_S result.Should().BeTrue("bundling should succeed when placeholders have --output-products"); Collector.Errors.Should().Be(0, "no errors expected when validation passes"); - var bundleContent = await FileSystem.File.ReadAllTextAsync("bundle.yaml", TestContext.Current.CancellationToken); + var bundleContent = await FileSystem.File.ReadAllTextAsync(outputPath, TestContext.Current.CancellationToken); bundleContent.Should().Contain("Release includes 9.2.0 with ga features from elastic/elasticsearch", "placeholders should be substituted correctly"); } @@ -6022,11 +6024,12 @@ public async Task BundleChangelogs_OptionModeWithConfigDescriptionAndPlaceholder { // Arrange - test validation with description that could come from config (simulated via CLI) CreateSampleChangelogs(); + var outputPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml"); var input = new BundleChangelogsArguments { Directory = _changelogDir, All = true, - Output = "bundle.yaml", + Output = outputPath, Description = "Version {version} includes {lifecycle} updates from {owner}/{repo}" // Simulate config-provided description // No OutputProducts - should fail validation }; From 4ec5cd8132869a61dc7c3a8a7fb04a2cba266a08 Mon Sep 17 00:00:00 2001 From: Felipe Cotti Date: Thu, 9 Apr 2026 17:39:26 -0300 Subject: [PATCH 6/7] Apply suggested fixes --- config/changelog.example.yml | 16 +-- .../ReleaseNotes/BundleLoader.cs | 15 +-- .../Bundling/BundleDescriptionSubstitution.cs | 57 +++++++++ .../Bundling/ChangelogBundlingService.cs | 68 ++--------- .../Asciidoc/ChangelogAsciidocRenderer.cs | 4 +- .../Rendering/ChangelogRenderContext.cs | 4 +- .../Rendering/ChangelogRenderingService.cs | 2 +- .../Markdown/IndexMarkdownRenderer.cs | 4 +- .../docs-builder/Commands/ChangelogCommand.cs | 2 + .../Changelogs/BundleChangelogsTests.cs | 7 +- .../BundleDescriptionSubstitutionTests.cs | 108 ++++++++++++++++++ 11 files changed, 199 insertions(+), 88 deletions(-) create mode 100644 src/services/Elastic.Changelog/Bundling/BundleDescriptionSubstitution.cs create mode 100644 tests/Elastic.Changelog.Tests/Changelogs/BundleDescriptionSubstitutionTests.cs diff --git a/config/changelog.example.yml b/config/changelog.example.yml index 6dde7d776..82c75e76c 100644 --- a/config/changelog.example.yml +++ b/config/changelog.example.yml @@ -215,9 +215,9 @@ bundle: # Optional: default description text for bundles. Supports {version}, {lifecycle}, {owner}, and {repo} placeholders. # Use YAML literal block scalar (|) for multiline descriptions. See docs/contribute/changelog.md for examples. # description: | - # 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}). + # 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}). # 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). @@ -253,11 +253,11 @@ bundle: # # output_products: "elasticsearch {version}" # # Optional: profile-specific description (overrides bundle.description) # # description: | - # # Elasticsearch {version} includes: - # # - Performance improvements - # # - Bug fixes and stability enhancements - # # - # # Download the release binaries: https://github.com/{owner}/{repo}/releases/tag/v{version} + # # Elasticsearch {version} includes: + # # - Performance improvements + # # - Bug fixes and stability enhancements + # # + # # Download the release binaries: https://github.com/{owner}/{repo}/releases/tag/v{version} # 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/src/Elastic.Documentation.Configuration/ReleaseNotes/BundleLoader.cs b/src/Elastic.Documentation.Configuration/ReleaseNotes/BundleLoader.cs index 7e38f489f..244c1306c 100644 --- a/src/Elastic.Documentation.Configuration/ReleaseNotes/BundleLoader.cs +++ b/src/Elastic.Documentation.Configuration/ReleaseNotes/BundleLoader.cs @@ -217,20 +217,11 @@ private static LoadedBundle MergeBundleGroup(IGrouping gro // Use the first bundle's metadata as the base var first = bundlesList[0]; - // Merge descriptions from all bundles - var descriptions = bundlesList + // Use first description only; multi-bundle description merging is not yet supported + var mergedDescription = bundlesList .Select(b => b.Data?.Description) - .Where(d => !string.IsNullOrEmpty(d)) - .ToList(); - - var mergedDescription = descriptions.Count switch - { - 0 => null, - 1 => descriptions[0], - _ => string.Join("\n\n", descriptions) - }; + .FirstOrDefault(d => !string.IsNullOrEmpty(d)); - // Create merged bundle data with combined description var mergedData = first.Data with { Description = mergedDescription }; return new LoadedBundle( diff --git a/src/services/Elastic.Changelog/Bundling/BundleDescriptionSubstitution.cs b/src/services/Elastic.Changelog/Bundling/BundleDescriptionSubstitution.cs new file mode 100644 index 000000000..ec52f02b0 --- /dev/null +++ b/src/services/Elastic.Changelog/Bundling/BundleDescriptionSubstitution.cs @@ -0,0 +1,57 @@ +// 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 + +namespace Elastic.Changelog.Bundling; + +/// +/// Helper for performing placeholder substitution in bundle descriptions. +/// Supports {version}, {lifecycle}, {owner}, and {repo} placeholders. +/// +internal static class BundleDescriptionSubstitution +{ + /// + /// Substitutes placeholders in a description string with provided values. + /// + /// The description string containing placeholders + /// Version value for {version} placeholder + /// Lifecycle value for {lifecycle} placeholder + /// Owner value for {owner} placeholder + /// Repository value for {repo} placeholder + /// If true, validates that all used placeholders can be resolved + /// Description with placeholders substituted + /// When validateResolvable is true and placeholders cannot be resolved + public static string SubstitutePlaceholders( + string description, + string? version, + string? lifecycle, + string? owner, + string? repo, + bool validateResolvable = false) + { + if (string.IsNullOrEmpty(description)) + return description; + + if (validateResolvable) + { + var missingValues = new List(); + if (description.Contains("{version}") && string.IsNullOrEmpty(version)) + missingValues.Add("version"); + if (description.Contains("{lifecycle}") && string.IsNullOrEmpty(lifecycle)) + missingValues.Add("lifecycle"); + if (description.Contains("{owner}") && string.IsNullOrEmpty(owner)) + missingValues.Add("owner"); + if (description.Contains("{repo}") && string.IsNullOrEmpty(repo)) + missingValues.Add("repo"); + + if (missingValues.Count > 0) + throw new InvalidOperationException($"Cannot resolve placeholders: {string.Join(", ", missingValues)}"); + } + + return description + .Replace("{version}", version ?? string.Empty) + .Replace("{lifecycle}", lifecycle ?? string.Empty) + .Replace("{owner}", owner ?? string.Empty) + .Replace("{repo}", repo ?? string.Empty); + } +} diff --git a/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs b/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs index 600f32881..2b4d130a3 100644 --- a/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs +++ b/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs @@ -23,57 +23,6 @@ namespace Elastic.Changelog.Bundling; -/// -/// Helper for performing placeholder substitution in bundle descriptions -/// -internal static class BundleDescriptionSubstitution -{ - /// - /// Substitutes placeholders in a description string with provided values - /// - public static string SubstitutePlaceholders(string description, string? version, string? lifecycle, string? owner, string? repo) => - SubstitutePlaceholders(description, version, lifecycle, owner, repo, validateResolvable: false); - - /// - /// Substitutes placeholders in a description string with provided values - /// - /// The description string containing placeholders - /// Version value for {version} placeholder - /// Lifecycle value for {lifecycle} placeholder - /// Owner value for {owner} placeholder - /// Repository value for {repo} placeholder - /// If true, validates that all used placeholders can be resolved - /// Description with placeholders substituted - /// When validateResolvable is true and placeholders cannot be resolved - public static string SubstitutePlaceholders(string description, string? version, string? lifecycle, string? owner, string? repo, bool validateResolvable) - { - if (string.IsNullOrEmpty(description)) - return description; - - if (validateResolvable) - { - var missingValues = new List(); - if (description.Contains("{version}") && string.IsNullOrEmpty(version)) - missingValues.Add("version"); - if (description.Contains("{lifecycle}") && string.IsNullOrEmpty(lifecycle)) - missingValues.Add("lifecycle"); - if (description.Contains("{owner}") && string.IsNullOrEmpty(owner)) - missingValues.Add("owner"); - if (description.Contains("{repo}") && string.IsNullOrEmpty(repo)) - missingValues.Add("repo"); - - if (missingValues.Count > 0) - throw new InvalidOperationException($"Cannot resolve placeholders: {string.Join(", ", missingValues)}"); - } - - return description - .Replace("{version}", version ?? string.Empty) - .Replace("{lifecycle}", lifecycle ?? string.Empty) - .Replace("{owner}", owner ?? string.Empty) - .Replace("{repo}", repo ?? string.Empty); - } -} - /// /// Arguments for the BundleChangelogs method /// @@ -385,7 +334,6 @@ public async Task BundleChangelogs(IDiagnosticsCollector collector, Bundle // Apply description with placeholder substitution if (!string.IsNullOrEmpty(input.Description)) { - // Get version/lifecycle from the first output product or first product in the bundle var version = (input.OutputProducts?.Count > 0 ? input.OutputProducts[0].Target : null) ?? (bundleData.Products.Count > 0 ? bundleData.Products[0].Target : null); var lifecycle = (input.OutputProducts?.Count > 0 ? input.OutputProducts[0].Lifecycle : null) @@ -393,10 +341,17 @@ public async Task BundleChangelogs(IDiagnosticsCollector collector, Bundle var owner = input.Owner ?? "elastic"; var repo = input.Repo ?? (bundleData.Products.Count > 0 ? bundleData.Products[0].ProductId : null) ?? "unknown"; - var substitutedDescription = BundleDescriptionSubstitution.SubstitutePlaceholders( - input.Description, version, lifecycle, owner, repo); - - bundleData = bundleData with { Description = substitutedDescription }; + try + { + var substitutedDescription = BundleDescriptionSubstitution.SubstitutePlaceholders( + input.Description, version, lifecycle, owner, repo, validateResolvable: true); + bundleData = bundleData with { Description = substitutedDescription }; + } + catch (InvalidOperationException ex) + { + collector.EmitError(string.Empty, $"Description placeholder substitution failed: {ex.Message}"); + return false; + } } // Write bundle file @@ -674,7 +629,6 @@ private bool ValidateInput(IDiagnosticsCollector collector, BundleChangelogsArgu private static bool ValidatePlaceholderUsage(IDiagnosticsCollector collector, BundleChangelogsArguments input) { - // Only validate in option-based mode (profile mode has separate validation) if (!string.IsNullOrEmpty(input.Profile)) return true; diff --git a/src/services/Elastic.Changelog/Rendering/Asciidoc/ChangelogAsciidocRenderer.cs b/src/services/Elastic.Changelog/Rendering/Asciidoc/ChangelogAsciidocRenderer.cs index e7a817c6e..edce4ad1e 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 description if present - if (!string.IsNullOrEmpty(context.Description)) + if (!string.IsNullOrEmpty(context.BundleDescription)) { - _ = sb.AppendLine(context.Description); + _ = sb.AppendLine(context.BundleDescription); _ = sb.AppendLine(); } diff --git a/src/services/Elastic.Changelog/Rendering/ChangelogRenderContext.cs b/src/services/Elastic.Changelog/Rendering/ChangelogRenderContext.cs index e77f93401..a2f0d5633 100644 --- a/src/services/Elastic.Changelog/Rendering/ChangelogRenderContext.cs +++ b/src/services/Elastic.Changelog/Rendering/ChangelogRenderContext.cs @@ -27,7 +27,7 @@ public record ChangelogRenderContext public required Dictionary EntryToHideLinks { get; init; } public ChangelogConfiguration? Configuration { get; init; } /// - /// Optional description for the changelog. Only set when there's a single bundle with a description (MVP approach). + /// Optional bundle-level introductory description. Only set when there's a single bundle with a description (MVP approach). /// - public string? Description { get; init; } + public string? BundleDescription { get; init; } } diff --git a/src/services/Elastic.Changelog/Rendering/ChangelogRenderingService.cs b/src/services/Elastic.Changelog/Rendering/ChangelogRenderingService.cs index 49b1e65ba..c78d4ba27 100644 --- a/src/services/Elastic.Changelog/Rendering/ChangelogRenderingService.cs +++ b/src/services/Elastic.Changelog/Rendering/ChangelogRenderingService.cs @@ -308,7 +308,7 @@ private static ChangelogRenderContext BuildRenderContext( EntryToOwner = entryToOwner, EntryToHideLinks = entryToHideLinks, Configuration = config, - Description = description + BundleDescription = description }; } diff --git a/src/services/Elastic.Changelog/Rendering/Markdown/IndexMarkdownRenderer.cs b/src/services/Elastic.Changelog/Rendering/Markdown/IndexMarkdownRenderer.cs index 50c23dfe6..3d9b19234 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 description if present - if (!string.IsNullOrEmpty(context.Description)) + if (!string.IsNullOrEmpty(context.BundleDescription)) { _ = sb.AppendLine(); - _ = sb.AppendLine(context.Description); + _ = sb.AppendLine(context.BundleDescription); } if (otherLinks.Count > 0) diff --git a/src/tooling/docs-builder/Commands/ChangelogCommand.cs b/src/tooling/docs-builder/Commands/ChangelogCommand.cs index 186763dc4..f071af8c6 100644 --- a/src/tooling/docs-builder/Commands/ChangelogCommand.cs +++ b/src/tooling/docs-builder/Commands/ChangelogCommand.cs @@ -626,6 +626,8 @@ public async Task Bundle( forbidden.Add("--config"); if (!string.IsNullOrWhiteSpace(directory)) forbidden.Add("--directory"); + if (!string.IsNullOrWhiteSpace(description)) + forbidden.Add("--description"); if (forbidden.Count > 0) { diff --git a/tests/Elastic.Changelog.Tests/Changelogs/BundleChangelogsTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/BundleChangelogsTests.cs index 52b915966..d7a671b0f 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/BundleChangelogsTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/BundleChangelogsTests.cs @@ -5972,7 +5972,7 @@ public async Task BundleChangelogs_OptionModeWithPlaceholdersButNoOutputProducts Directory = _changelogDir, All = true, Output = outputPath, - Description = "Release includes {version} with {lifecycle} features from {owner}/{repo}" // Has placeholders but no --output-products + Description = "Release includes {version} with {lifecycle} features from {owner}/{repo}" }; // Act @@ -6022,7 +6022,7 @@ public async Task BundleChangelogs_OptionModeWithPlaceholdersAndOutputProducts_S [Fact] public async Task BundleChangelogs_OptionModeWithConfigDescriptionAndPlaceholders_ReturnsError() { - // Arrange - test validation with description that could come from config (simulated via CLI) + // Arrange - config-provided description with placeholders but no --output-products CreateSampleChangelogs(); var outputPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml"); var input = new BundleChangelogsArguments @@ -6030,8 +6030,7 @@ public async Task BundleChangelogs_OptionModeWithConfigDescriptionAndPlaceholder Directory = _changelogDir, All = true, Output = outputPath, - Description = "Version {version} includes {lifecycle} updates from {owner}/{repo}" // Simulate config-provided description - // No OutputProducts - should fail validation + Description = "Version {version} includes {lifecycle} updates from {owner}/{repo}" }; // Act diff --git a/tests/Elastic.Changelog.Tests/Changelogs/BundleDescriptionSubstitutionTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/BundleDescriptionSubstitutionTests.cs new file mode 100644 index 000000000..99353bc81 --- /dev/null +++ b/tests/Elastic.Changelog.Tests/Changelogs/BundleDescriptionSubstitutionTests.cs @@ -0,0 +1,108 @@ +// 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; + +namespace Elastic.Changelog.Tests.Changelogs; + +public class BundleDescriptionSubstitutionTests +{ + [Fact] + public void SubstitutePlaceholders_AllPlaceholdersResolved_ReturnsSubstitutedString() + { + var result = BundleDescriptionSubstitution.SubstitutePlaceholders( + "Release {version} ({lifecycle}) from {owner}/{repo}", + "9.2.0", "ga", "elastic", "elasticsearch"); + + result.Should().Be("Release 9.2.0 (ga) from elastic/elasticsearch"); + } + + [Fact] + public void SubstitutePlaceholders_NullValues_ReplacedWithEmptyString() + { + var result = BundleDescriptionSubstitution.SubstitutePlaceholders( + "Version {version} by {owner}", null, null, null, null); + + result.Should().Be("Version by "); + } + + [Fact] + public void SubstitutePlaceholders_EmptyDescription_ReturnsEmpty() + { + var result = BundleDescriptionSubstitution.SubstitutePlaceholders( + "", "9.2.0", "ga", "elastic", "elasticsearch"); + + result.Should().BeEmpty(); + } + + [Fact] + public void SubstitutePlaceholders_NoPlaceholders_ReturnsOriginal() + { + var result = BundleDescriptionSubstitution.SubstitutePlaceholders( + "Just a plain description.", "9.2.0", "ga", "elastic", "elasticsearch"); + + result.Should().Be("Just a plain description."); + } + + [Fact] + public void SubstitutePlaceholders_PartialPlaceholders_OnlySubstitutesPresent() + { + var result = BundleDescriptionSubstitution.SubstitutePlaceholders( + "Download: https://github.com/{owner}/{repo}/releases", + null, null, "elastic", "elasticsearch"); + + result.Should().Be("Download: https://github.com/elastic/elasticsearch/releases"); + } + + [Fact] + public void SubstitutePlaceholders_ValidateResolvable_ThrowsWhenVersionMissing() + { + var act = () => BundleDescriptionSubstitution.SubstitutePlaceholders( + "Release {version}", null, null, null, null, validateResolvable: true); + + act.Should().Throw() + .WithMessage("*version*"); + } + + [Fact] + public void SubstitutePlaceholders_ValidateResolvable_ThrowsWhenMultipleMissing() + { + var act = () => BundleDescriptionSubstitution.SubstitutePlaceholders( + "v{version} ({lifecycle}) from {owner}/{repo}", + null, null, null, null, validateResolvable: true); + + act.Should().Throw() + .WithMessage("*version*lifecycle*owner*repo*"); + } + + [Fact] + public void SubstitutePlaceholders_ValidateResolvable_SucceedsWhenAllProvided() + { + var result = BundleDescriptionSubstitution.SubstitutePlaceholders( + "v{version} from {owner}/{repo}", + "9.2.0", "ga", "elastic", "elasticsearch", validateResolvable: true); + + result.Should().Be("v9.2.0 from elastic/elasticsearch"); + } + + [Fact] + public void SubstitutePlaceholders_ValidateResolvable_IgnoresUnusedNullValues() + { + var result = BundleDescriptionSubstitution.SubstitutePlaceholders( + "Download from {owner}/{repo}", + null, null, "elastic", "elasticsearch", validateResolvable: true); + + result.Should().Be("Download from elastic/elasticsearch"); + } + + [Fact] + public void SubstitutePlaceholders_NullDescription_ReturnsNull() + { + var result = BundleDescriptionSubstitution.SubstitutePlaceholders( + null!, "9.2.0", "ga", "elastic", "elasticsearch"); + + result.Should().BeNull(); + } +} From 6f9cbdb4ed14ec95c41967526d2b3b4b4f934e21 Mon Sep 17 00:00:00 2001 From: Felipe Cotti Date: Thu, 9 Apr 2026 17:51:59 -0300 Subject: [PATCH 7/7] Fix bundle loader behavior --- .../ReleaseNotes/BundleLoader.cs | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/Elastic.Documentation.Configuration/ReleaseNotes/BundleLoader.cs b/src/Elastic.Documentation.Configuration/ReleaseNotes/BundleLoader.cs index 244c1306c..b25a45e1a 100644 --- a/src/Elastic.Documentation.Configuration/ReleaseNotes/BundleLoader.cs +++ b/src/Elastic.Documentation.Configuration/ReleaseNotes/BundleLoader.cs @@ -217,10 +217,17 @@ private static LoadedBundle MergeBundleGroup(IGrouping gro // Use the first bundle's metadata as the base var first = bundlesList[0]; - // Use first description only; multi-bundle description merging is not yet supported - var mergedDescription = bundlesList + var descriptions = bundlesList .Select(b => b.Data?.Description) - .FirstOrDefault(d => !string.IsNullOrEmpty(d)); + .Where(d => !string.IsNullOrEmpty(d)) + .ToList(); + + var mergedDescription = descriptions.Count switch + { + 0 => null, + 1 => descriptions[0], + _ => string.Join("\n\n", descriptions) + }; var mergedData = first.Data with { Description = mergedDescription };