Skip to content

Conversation

@lcawl
Copy link
Contributor

@lcawl lcawl commented Dec 11, 2025

Added a docs-builder changelog bundle command that bundles changelog fragments into a single YAML file.

It's purpose is to create files like https://github.com/elastic/elasticsearch/blob/main/docs/release-notes/changelog-bundles/9.2.2.yml and https://github.com/elastic/elastic-agent/blob/main/changelog/9.2.2.yaml (which were created by the gradlew bundleChangelogs and elastic-agent-changelog-tool build commands respectively).

The command currently can create the list based on (a) all changelogs in a folder, (b) changelogs that have specific product and target values, (c) changelogs that have specific PR values. Only (a) was existing functionality. The long-term goal is to have these manifests generated from the list of PRs associated with a github release or deployment event (then optionally add known issues and security issues and remove feature-flagged changelogs as desired).

NOTE: #2352, #2408, and #2409 were also merged into this PR, which are stop-gap items (until publishing can be handled from a centralized site) to generate MD files from one or more changelog bundles (as is currently accomplished by the gradlew generateReleaseNotes and elastic-agent-changelog-tool render commands in Elasticsearch and Elastic Agent respectively).

Usage

Bundle changelog fragments into a single YAML file

Options:
  --directory <string?>                     Optional: Directory containing changelog YAML files. Defaults to current directory [Default: null]
  --output <string?>                        Optional: Output file path for the bundled changelog. Defaults to 'changelog-bundle.yaml' in the input directory [Default: null]
  --all                                     Include all changelogs in the directory
  --input-products <List<ProductInfo>?>     Filter by products in format "product target lifecycle, ..." (e.g., "cloud-serverless 2025-12-02, cloud-serverless 2025-12-06") [Default: null]
  --output-products <List<ProductInfo>?>    Explicitly set the products array in the output file in format "product target lifecycle, ...". Overrides any values from changelogs. [Default: null]
  --resolve                                 Copy the contents of each changelog file into the entries array
  --prs <string[]?>                         Filter by pull request URLs or numbers (can specify multiple times) [Default: null]
  --prs-file <string?>                      Path to a newline-delimited file containing PR URLs or numbers [Default: null]
  --owner <string?>                         Optional: GitHub repository owner (used when PRs are specified as numbers) [Default: null]
  --repo <string?>                          Optional: GitHub repository name (used when PRs are specified as numbers) [Default: null]

Examples

Bundle a list of PRs

You can use the --prs option (with the --repo and --owner options if you provide only the PR numbers) to create a bundle of the changelogs that relate to those pull requests. For example:

./docs-builder changelog bundle --prs 108875,135873,136886 --repo elasticsearch --owner elastic

Bundle by PRs in file

If you specify the --prs-file option, the bundle contains only changelogs that relate to those pull requests.
For example, if you have a file with the following PR URLs:

https://github.com/elastic/elasticsearch/pull/108875
https://github.com/elastic/elasticsearch/pull/135873
https://github.com/elastic/elasticsearch/pull/136886
https://github.com/elastic/elasticsearch/pull/137126

Run the bundle command to reference this file and explicitly set the bundle's product metadata:

./docs-builder changelog bundle --prs-file test/9.2.2a.txt --output-products "elasticsearch 9.2.2"

Alternatively, if the file contains just a list of PR numbers, you must specify the --repo and --owner options:

./docs-builder changelog bundle --prs-file test/9.2.2b.txt --output-products "elasticsearch 9.2.2" --repo elasticsearch --owner elastic

Both variations create a bundle like this:

products:
- product: elasticsearch
   target: 9.2.2
entries:
- file:
    name: 1765507819-fix-ml-calendar-event-update-scalability-issues.yaml
    checksum: 069b59edb14594e0bc3b70365e81626bde730ab7
- file:
    name: 1765507798-convert-bytestransportresponse-when-proxying-respo.yaml
    checksum: c6dbd4730bf34dbbc877c16c042e6578dd108b62
- file:
    name: 1765507839-use-ivf_pq-for-gpu-index-build-for-large-datasets.yaml
    checksum: 451d60283fe5df426f023e824339f82c2900311e
- file:
    name: 1765507778-break-on-fielddata-when-building-global-ordinals.yaml
    checksum: 70d197d96752c05b6595edffe6fe3ba3d055c845

In this example, none of the changelogs had target or lifecycle product values, therefore there's no version info in this bundle.

Bundle by product and target

If you specify the --input-products option, the bundle contains only changelogs that contain one of the specified values:

docs-builder changelog bundle --input-products "cloud-serverless 2025-12-02, cloud-serverless 2025-12-06"

Even if the changelogs also have other product values, only those specified in the bundle command appear in the output:

products:
- product: cloud-serverless
  target: 2025-12-02
- product: cloud-serverless
  target: 2025-12-06
entries:
- file:
    name: 1765495972-fixes-enrich-and-lookup-join-resolution-based-on-m.yaml
    checksum: 6c3243f56279b1797b5dfff6c02ebf90b9658464
- file:
    name: 1-test.yaml
    checksum: 0229ff4e908a0392af00e0905db94134616e6457

Bundle all changelog files

./docs-builder changelog bundle --directory . --all

NOTE: If you have changelogs that apply to multiple products and/or versions in your directory, this can result in a potentially unrealistic bundle. This command option was added to replicate existing behaviour in the elastic-agent-changelog-tool build and gradlew bundleChangelog commands and will likely be deprecated.

products:
- product: cloud-serverless
  target: 2025-12-02
- product: elasticsearch
  target: 9.2.3
- product: elasticsearch
  target: 9.3.0
- product: kibana
entries:
- file:
    name: 1765319409-fixes-enrich-and-lookup-join-resolution-based-on-m.yaml
    checksum: a01d40dc3673d681452373e5b78d1f01da609ff7
- file:
    name: 1765415340-[es|ql]-take-top_snippets-out-of-snapshot.yaml
    checksum: 4be2d3a14154b432f3a1d83ebfbd5568c69cbd1d
...

Copy the changelogs into the bundle

To include the contents of the changelogs, use the --resolve option:

./docs-builder changelog bundle --prs 108875,135873,136886 --repo elasticsearch --owner elastic --output-products "elasticsearch 9.2.2" --resolve

This generates output that's similar to the existing Elastic Agent bundles, for example https://github.com/elastic/elastic-agent/blob/main/changelog/9.2.2.yaml

products:
- product: elasticsearch
   target: 9.2.2
entries:
- file:
    name: 1765507819-fix-ml-calendar-event-update-scalability-issues.yaml
    checksum: 069b59edb14594e0bc3b70365e81626bde730ab7
  type: bug-fix
  title: Fix ML calendar event update scalability issues
  products:
  - product: elasticsearch
  areas:
  - Machine Learning
  pr: https://github.com/elastic/elasticsearch/pull/136886
...

Features

  1. Data models (BundledChangelogData.cs):
    • BundledChangelogData - root structure with products and entries
    • BundledProduct - product/version pairs
    • BundledEntry - individual changelog entries with all required fields
    • BundledFile - file metadata with name and checksum
  2. Bundle service method (ChangelogService.BundleChangelogs):
    • Reads all YAML files from a directory
    • Deserializes changelog fragments
    • Filters entries based on options:
      • --all: Include all changelogs
      • --input-products: Filter by product and version (e.g., "elastic-agent 9.1.5")
      • --prs: Filter by PR URLs/numbers (supports multiple)
      • --prs-file: Filter by PRs from a newline-delimited file
    • PR parsing supports:
      • Full URLs: https://github.com/owner/repo/pull/123
      • Short format: owner/repo#123
      • PR numbers with --owner and --repo options
    • Computes SHA1 checksums for each file
    • Generates bundled YAML output
  3. CLI command (ChangelogCommand.Bundle):
    • Added bundle subcommand with all filtering options
    • Validates that exactly one filter option is specified
    • Supports --output to specify output file path
    • When using --prs or --prs-file, the command:
      • Tracks which PRs were matched in changelog files
      • Emits warnings for any PRs that don't have matching changelog files
      • Uses collector.EmitWarning() to report unmatched PRs
    • When using --input-products option, the bundle filters changelog entries by that product and target value.
    • Without --resolve, bundle entries only contain file with name and checksum
    • With --resolve, bundle entries include all changelog fields from the source files, with validation

Output format

The bundled YAML matches the requested format:

  • products array with product and version
  • entries array with changelog files with name and checksum (and optionally the full contents of each changelog)

The command is ready to use. Build succeeds and the help text displays correctly.

Test coverage

Added 14 tests for the docs-builder changelog bundle command, covering:

  1. Basic functionality:
    • BundleChangelogs_WithAllOption_CreatesValidBundle - Tests bundling all changelogs with --all
    • BundleChangelogs_WithMultipleProducts_IncludesAllProducts - Tests handling multiple products
  2. Filtering options:
    • BundleChangelogs_WithProductVersionFilter_FiltersCorrectly - Tests --input-products filtering
    • BundleChangelogs_WithPrsFilter_FiltersCorrectly - Tests --prs filtering
    • BundleChangelogs_WithPrsFileFilter_FiltersCorrectly - Tests --prs-file filtering
    • BundleChangelogs_WithPrNumberAndOwnerRepo_FiltersCorrectly - Tests PR number with --owner and --repo
    • BundleChangelogs_WithShortPrFormat_FiltersCorrectly - Tests short PR format (owner/repo#123)
  3. Warning behavior:
    • BundleChangelogs_WithPrsFilterAndUnmatchedPrs_EmitsWarnings - Verifies warnings for unmatched PRs
  4. Error cases:
    • BundleChangelogs_WithNoMatchingFiles_ReturnsError - No matching files
    • BundleChangelogs_WithInvalidDirectory_ReturnsError - Invalid directory
    • BundleChangelogs_WithNoFilterOption_ReturnsError - No filter option specified
    • BundleChangelogs_WithMultipleFilterOptions_ReturnsError - Multiple filter options
    • BundleChangelogs_WithInvalidProductVersionFormat_ReturnsError - Invalid product:version format
    • BundleChangelogs_WithInvalidPrsFile_ReturnsError - Invalid PRs file path
  5. File resolution:
    • BundleChangelogs_WithResolve_CopiesChangelogContents - Verifies resolve copies all changelog fields
    • BundleChangelogs_WithResolveAndMissingTitle_ReturnsError - Validates missing title fails
    • BundleChangelogs_WithResolveAndMissingType_ReturnsError - Validates missing type fails
    • BundleChangelogs_WithResolveAndMissingProducts_ReturnsError - Validates missing products fails
    • BundleChangelogs_WithResolveAndInvalidProduct_ReturnsError - Validates invalid product entries fail

All tests follow the same patterns as the existing CreateChangelog tests:

  • Use TestDiagnosticsCollector to verify errors and warnings
  • Use temporary directories for file operations
  • Verify YAML output content
  • Test both success and error scenarios

All 14 tests pass.

Outstanding items

  • The bundle command should also have an option to filter out changelogs that contain specific feature flag identifiers. This can be accomplished in a subsequent PR.
  • The render command doesn't generate the right PR URLs in https://github.com/elastic/cloud/pull/150210 (maybe because of problems accessing private repo?). Thought is required about whether the changelogs should contain the full URL or whether the render command should be able to pull the repo info from the configuration file by default.

Generative AI disclosure

  1. Did you use a generative AI (GenAI) tool to assist in creating this contribution?
  • Yes
  • No
  1. If you answered "Yes" to the previous question, please specify the tool(s) and model(s) used (e.g., Google Gemini, OpenAI ChatGPT-4, etc.).

Tool(s) and model(s) used: composer-1 agent

@github-actions
Copy link

github-actions bot commented Dec 11, 2025

@lcawl lcawl changed the title [WIP] Changelog manifest command Add changelog manifest command Dec 12, 2025
@lcawl lcawl marked this pull request as ready for review December 12, 2025 07:43
@lcawl lcawl requested review from a team as code owners December 12, 2025 07:43
@lcawl lcawl requested a review from reakaleek December 12, 2025 07:43
@lcawl lcawl changed the title Add changelog manifest command Add changelog bundle command Dec 12, 2025
@lcawl lcawl marked this pull request as draft December 12, 2025 14:12
@lcawl lcawl marked this pull request as ready for review December 12, 2025 15:00
@lcawl lcawl changed the title Add changelog bundle command Add changelog bundle and render commands Dec 29, 2025
Comment on lines +1111 to +1122
// Validate required fields in resolved entry
if (string.IsNullOrWhiteSpace(entry.Title))
{
collector.EmitError(bundleInput.BundleFile, $"Entry in bundle is missing required field: title");
return false;
}

if (string.IsNullOrWhiteSpace(entry.Type))
{
collector.EmitError(bundleInput.BundleFile, $"Entry in bundle is missing required field: type");
return false;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// Validate required fields in resolved entry
if (string.IsNullOrWhiteSpace(entry.Title))
{
collector.EmitError(bundleInput.BundleFile, $"Entry in bundle is missing required field: title");
return false;
}
if (string.IsNullOrWhiteSpace(entry.Type))
{
collector.EmitError(bundleInput.BundleFile, $"Entry in bundle is missing required field: type");
return false;
}

None of these can happen inside here, so it should be removed.

Comment on lines +1840 to +1844
[GeneratedRegex(@"\d+$", RegexOptions.None)]
private static partial Regex PrNumberRegex();

[GeneratedRegex(@"\d+$", RegexOptions.None)]
private static partial Regex IssueNumberRegex();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One of them can go; you can use a broader name.

return string.Empty;

// Capitalize first letter and ensure ends with period
var result = char.ToUpperInvariant(text[0]) + text.Substring(1);
Copy link
Contributor

@cotti cotti Jan 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit:

Suggested change
var result = char.ToUpperInvariant(text[0]) + text.Substring(1);
var result = text.Length < 2
? char.ToUpperInvariant(text[0]).ToString()
: char.ToUpperInvariant(text[0]) + text[1..];

if (string.IsNullOrWhiteSpace(area))
return string.Empty;

var result = char.ToUpperInvariant(area[0]) + area.Substring(1);
Copy link
Contributor

@cotti cotti Jan 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit:

Suggested change
var result = char.ToUpperInvariant(area[0]) + area.Substring(1);
var result = area.Length < 2
? char.ToUpperInvariant(area[0]).ToString()
: char.ToUpperInvariant(area[0]) + area[1..];

Comment on lines +1407 to +1419
var features = entriesByType.GetValueOrDefault("feature", []);
var enhancements = entriesByType.GetValueOrDefault("enhancement", []);
var security = entriesByType.GetValueOrDefault("security", []);
var bugFixes = entriesByType.GetValueOrDefault("bug-fix", []);

if (features.Count == 0 && enhancements.Count == 0 && security.Count == 0 && bugFixes.Count == 0)
{
// Still create file with "no changes" message
}

var hasBreakingChanges = entriesByType.ContainsKey("breaking-change");
var hasDeprecations = entriesByType.ContainsKey("deprecation");
var hasKnownIssues = entriesByType.ContainsKey("known-issue");
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's have a static class defining the type strings instead of using them directly here.

Comment on lines +534 to +537
if (input.InputProducts != null && input.InputProducts.Count > 0)
filterCount++;
if (input.Prs != null && input.Prs.Length > 0)
filterCount++;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit:

Suggested change
if (input.InputProducts != null && input.InputProducts.Count > 0)
filterCount++;
if (input.Prs != null && input.Prs.Length > 0)
filterCount++;
if (input.InputProducts is { Count: > 0 })
filterCount++;
if (input.Prs is { Length: > 0 })
filterCount++;

.Where(p => !string.IsNullOrWhiteSpace(p))
.ToArray();

if (input.Prs != null && input.Prs.Length > 0)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit:

Suggested change
if (input.Prs != null && input.Prs.Length > 0)
if (input.Prs is { Length: > 0 })

_ = prsToMatch.Add(pr);
}
}
else if (input.Prs != null && input.Prs.Length > 0)
Copy link
Contributor

@cotti cotti Jan 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit:

Suggested change
else if (input.Prs != null && input.Prs.Length > 0)
else if (input.Prs is { Length: > 0 })


// Build set of product/version combinations to filter by
var productsToMatch = new HashSet<(string product, string version)>();
if (input.InputProducts != null && input.InputProducts.Count > 0)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit:

Suggested change
if (input.InputProducts != null && input.InputProducts.Count > 0)
if (input.InputProducts is { Count: > 0 })


var data = deserializer.Deserialize<ChangelogData>(normalizedYaml);

if (data == null)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think data can't be null at this point; so this if can be removed.


// Set products array in output
// If --output-products was specified, use those values (override any from changelogs)
if (input.OutputProducts != null && input.OutputProducts.Count > 0)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit:

Suggested change
if (input.OutputProducts != null && input.OutputProducts.Count > 0)
if (input.OutputProducts is { Count: > 0 })

return false;
}

if (data.Products == null || data.Products.Count == 0)
Copy link
Contributor

@cotti cotti Jan 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

data.Products can't be null at this point.

Suggested change
if (data.Products == null || data.Products.Count == 0)
if (data.Products.Count == 0)

try
{
// Validate input
if (input.Bundles == null || input.Bundles.Count == 0)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bundles can't be null at this point:

Suggested change
if (input.Bundles == null || input.Bundles.Count == 0)
if (input.Bundles.Count == 0)

Comment on lines +1062 to +1079
if (bundledData == null)
{
collector.EmitError(bundleInput.BundleFile, "Failed to deserialize bundle file");
return false;
}

// Validate bundle has required structure
if (bundledData.Products == null)
{
collector.EmitError(bundleInput.BundleFile, "Bundle file is missing required field: products");
return false;
}

if (bundledData.Entries == null)
{
collector.EmitError(bundleInput.BundleFile, "Bundle file is missing required field: entries");
return false;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bundledData and these properties can't be null at this point. We need a distinction between actually missing the field (so it should be a nullable) and having it, with zero elements.

Copy link
Contributor

@cotti cotti left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is mostly OK, but there's a question not quite solved:

Models like BundledChangelogData are always initializing their collection properties - but the logic asks if they are null when it seems like what's needed is for them to be just empty. So for these, can we guarantee if an empty collection is valid or not?

If it is, these checks need to change from == null to Any(). If it's not, then the model needs to change to make them a nullable reference, not initialized if there's no data for them in the input.

Comment on lines +1062 to +1079
if (bundledData == null)
{
collector.EmitError(bundleInput.BundleFile, "Failed to deserialize bundle file");
return false;
}

// Validate bundle has required structure
if (bundledData.Products == null)
{
collector.EmitError(bundleInput.BundleFile, "Bundle file is missing required field: products");
return false;
}

if (bundledData.Entries == null)
{
collector.EmitError(bundleInput.BundleFile, "Bundle file is missing required field: entries");
return false;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bundledData and these properties can't be null at this point. We need a distinction between actually missing the field (so it should be a nullable) and having it, with zero elements.

And in this case, some of my nitpicks wouldn't apply: The current model is guaranteeing the creation of these collections in memory.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants