Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/cli/release/changelog-bundle.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@ docs-builder changelog bundle [options...] [-h|--help]
- `"* * *"` - match all changelogs (equivalent to `--all`)

`--output <string?>`
: Optional: The output file path for the bundle.
: Optional: The output path for the bundle.
: Can be either (1) a directory path, in which case `changelog-bundle.yaml` is created in that directory, or (2) a file path ending in `.yml` or `.yaml`.
: Defaults to `changelog-bundle.yaml` in the input directory.

`--output-products <List<ProductInfo>?>`
Expand Down
22 changes: 21 additions & 1 deletion docs/contribute/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ Bundle changelogs

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]
--output <string?> Optional: Output path for the bundled changelog. Can be either (1) a directory path, in which case 'changelog-bundle.yaml' is created in that directory, or (2) a file path ending in .yml or .yaml. Defaults to 'changelog-bundle.yaml' in the input directory [Default: null]
--all Include all changelogs in the directory. Only one filter option can be specified: `--all`, `--input-products`, or `--prs`.
--input-products <List<ProductInfo>?> Filter by products in format "product target lifecycle, ..." (e.g., "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). Only one filter option can be specified: `--all`, `--input-products`, or `--prs`. [Default: null]
--output-products <List<ProductInfo>?> Optional: Explicitly set the products array in the output file in format "product target lifecycle, ...". Overrides any values from changelogs. [Default: null]
Expand Down Expand Up @@ -312,6 +312,26 @@ entries:
When a changelog matches multiple `--input-products` filters, it appears only once in the bundle. This deduplication applies even when using `--all` or `--prs`.
:::

### Output file location

The `--output` option supports two formats:

1. **Directory path**: If you specify a directory path (without a filename), the command creates `changelog-bundle.yaml` in that directory:

```sh
docs-builder changelog bundle --all --output /path/to/output/dir
# Creates /path/to/output/dir/changelog-bundle.yaml
```

2. **File path**: If you specify a file path ending in `.yml` or `.yaml`, the command uses that exact path:

```sh
docs-builder changelog bundle --all --output /path/to/custom-bundle.yaml
# Creates /path/to/custom-bundle.yaml
```

If you specify a file path with a different extension (not `.yml` or `.yaml`), the command returns an error.

## Create documentation [render-changelogs]

The `docs-builder changelog render` command creates markdown files from changelog bundles for documentation purposes.
Expand Down
36 changes: 34 additions & 2 deletions src/tooling/docs-builder/Commands/ChangelogCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ async static (s, collector, state, ctx) => await s.CreateChangelog(collector, st
/// Bundle changelog files
/// </summary>
/// <param name="directory">Optional: Directory containing changelog YAML files. Defaults to current directory</param>
/// <param name="output">Optional: Output file path for the bundled changelog. Defaults to 'changelog-bundle.yaml' in the input directory</param>
/// <param name="output">Optional: Output path for the bundled changelog. Can be either (1) a directory path, in which case 'changelog-bundle.yaml' is created in that directory, or (2) a file path ending in .yml or .yaml. Defaults to 'changelog-bundle.yaml' in the input directory</param>
/// <param name="all">Include all changelogs in the directory. Only one filter option can be specified: `--all`, `--input-products`, or `--prs`.</param>
/// <param name="inputProducts">Filter by products in format "product target lifecycle, ..." (e.g., "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). Only one filter option can be specified: `--all`, `--input-products`, or `--prs`.</param>
/// <param name="outputProducts">Optional: Explicitly set the products array in the output file in format "product target lifecycle, ...". Overrides any values from changelogs.</param>
Expand Down Expand Up @@ -231,10 +231,42 @@ public async Task<int> Bundle(
}
}

// Process and validate output parameter
string? processedOutput = null;
if (!string.IsNullOrWhiteSpace(output))
{
var outputLower = output.ToLowerInvariant();
var endsWithYml = outputLower.EndsWith(".yml", StringComparison.OrdinalIgnoreCase);
var endsWithYaml = outputLower.EndsWith(".yaml", StringComparison.OrdinalIgnoreCase);

if (endsWithYml || endsWithYaml)
{
// It's a file path - use as-is
processedOutput = output;
}
else
{
// Check if it has a file extension (other than .yml/.yaml)
var extension = Path.GetExtension(output);
if (!string.IsNullOrEmpty(extension))
{
// Has an extension that's not .yml/.yaml - this is invalid
collector.EmitError(string.Empty, $"--output: If a filename is provided, it must end in .yml or .yaml. Found: {extension}");
_ = collector.StartAsync(ctx);
await collector.WaitForDrain();
await collector.StopAsync(ctx);
return 1;
}

// It's a directory path - append default filename
processedOutput = Path.Combine(output, "changelog-bundle.yaml");
}
}

var input = new ChangelogBundleInput
{
Directory = directory ?? Directory.GetCurrentDirectory(),
Output = output,
Output = processedOutput,
All = all,
InputProducts = inputProducts,
OutputProducts = outputProducts,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1089,7 +1089,7 @@ await fileSystem.File.WriteAllTextAsync(prsFile, """
var input = new ChangelogBundleInput
{
Directory = changelogDir,
Prs = new[] { prsFile },
Prs = [prsFile],
Output = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "bundle.yaml")
};

Expand Down Expand Up @@ -1553,7 +1553,7 @@ public async Task BundleChangelogs_WithNonExistentFileAsPrs_ReturnsError()
var input = new ChangelogBundleInput
{
Directory = changelogDir,
Prs = new[] { nonexistentFile },
Prs = [nonexistentFile],
Output = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "bundle.yaml")
};

Expand Down Expand Up @@ -1592,7 +1592,7 @@ public async Task BundleChangelogs_WithUrlAsPrs_TreatsAsPrIdentifier()
var input = new ChangelogBundleInput
{
Directory = changelogDir,
Prs = new[] { "https://github.com/elastic/elasticsearch/pull/123" },
Prs = ["https://github.com/elastic/elasticsearch/pull/123"],
Output = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "bundle.yaml")
};

Expand Down Expand Up @@ -1635,7 +1635,7 @@ public async Task BundleChangelogs_WithNonExistentFileAndOtherPrs_EmitsWarning()
var input = new ChangelogBundleInput
{
Directory = changelogDir,
Prs = new[] { nonexistentFile, "https://github.com/elastic/elasticsearch/pull/123" },
Prs = [nonexistentFile, "https://github.com/elastic/elasticsearch/pull/123"],
Output = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "bundle.yaml")
};

Expand Down Expand Up @@ -2065,7 +2065,7 @@ public async Task BundleChangelogs_WithMultipleTargets_WarningIncludesLifecycle(
_collector.Errors.Should().Be(0);
_collector.Warnings.Should().BeGreaterThan(0);
// Verify warning message includes lifecycle values
_collector.Diagnostics.Should().Contain(d =>
_collector.Diagnostics.Should().Contain(d =>
d.Message.Contains("Product 'elasticsearch' has multiple targets in bundle") &&
d.Message.Contains("9.2.0") &&
d.Message.Contains("9.2.0 beta") &&
Expand Down Expand Up @@ -2126,6 +2126,54 @@ public async Task BundleChangelogs_WithResolve_CopiesChangelogContents()
bundleContent.Should().Contain("description: This is a test feature");
}

[Fact]
public async Task BundleChangelogs_WithDirectoryOutputPath_CreatesDefaultFilename()
{
// Arrange
var service = new ChangelogService(_loggerFactory, _configurationContext, null);
var fileSystem = new FileSystem();
var changelogDir = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString());
fileSystem.Directory.CreateDirectory(changelogDir);

// Create test changelog file
var changelog1 = """
title: Test feature
type: feature
products:
- product: elasticsearch
target: 9.2.0
pr: https://github.com/elastic/elasticsearch/pull/100
""";

var file1 = fileSystem.Path.Combine(changelogDir, "1755268130-test-feature.yaml");
await fileSystem.File.WriteAllTextAsync(file1, changelog1, TestContext.Current.CancellationToken);

// Use a directory path with default filename (simulating command layer processing)
var outputDir = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString());
var outputPath = fileSystem.Path.Combine(outputDir, "changelog-bundle.yaml");

var input = new ChangelogBundleInput
{
Directory = changelogDir,
All = true,
Output = outputPath
};

// Act
var result = await service.BundleChangelogs(_collector, input, TestContext.Current.CancellationToken);

// Assert
result.Should().BeTrue();
_collector.Errors.Should().Be(0);
fileSystem.File.Exists(outputPath).Should().BeTrue("Output file should be created");

var bundleContent = await fileSystem.File.ReadAllTextAsync(outputPath, TestContext.Current.CancellationToken);
bundleContent.Should().Contain("products:");
bundleContent.Should().Contain("product: elasticsearch");
bundleContent.Should().Contain("entries:");
bundleContent.Should().Contain("name: 1755268130-test-feature.yaml");
}

[Fact]
public async Task BundleChangelogs_WithResolveAndMissingTitle_ReturnsError()
{
Expand Down Expand Up @@ -4435,7 +4483,7 @@ private static string ComputeSha1(string content)
{
var bytes = System.Text.Encoding.UTF8.GetBytes(content);
var hash = System.Security.Cryptography.SHA1.HashData(bytes);
return System.Convert.ToHexString(hash).ToLowerInvariant();
return Convert.ToHexString(hash).ToLowerInvariant();
}
}

Loading