diff --git a/docs/cli-schema.json b/docs/cli-schema.json
index 96d214901..2279db693 100644
--- a/docs/cli-schema.json
+++ b/docs/cli-schema.json
@@ -3246,7 +3246,7 @@
"name": "output",
"type": "string",
"required": false,
- "summary": "Output path for the bundled changelog (directory or .yml/.yaml file). Uses config bundle.output_directory or defaults to \u0027changelog-bundle.yaml\u0027 in the input directory. This option is not supported in profile-based commands, where bundle names are derived by convention as {repo}-{product}-{version}.yaml from the authoring repo and the profile\u0027s primary output product."
+ "summary": "Output path for the bundled changelog. A .yml/.yaml file is used as-is. A directory, or omitting this option, writes {repo}-{product}-{version}.yaml (from --repo / bundle.repo / git origin, --output-products then --input-products, then --release-version). Warns and writes changelog-bundle.yaml if product or version cannot be resolved, or {product}-{version}.yaml if no repo resolves. Not supported in profile-based commands (same convention from the profile)."
},
{
"role": "flag",
diff --git a/docs/cli/changelog/cmd-upload.md b/docs/cli/changelog/cmd-upload.md
index e3f63f1f9..1f397d17b 100644
--- a/docs/cli/changelog/cmd-upload.md
+++ b/docs/cli/changelog/cmd-upload.md
@@ -109,7 +109,7 @@ reconciled from public bucket state on the S3 events each upload emits; the
objects that only older CLI versions still write. See
[Changelog bundle registry](/development/changelog-bundle-registry.md).
-Profile-mode bundle files are named `{repo}-{product}-{version}.yaml` (for example `kibana-cloud-serverless-2026-08-27.yaml` and `elasticsearch-cloud-serverless-2026-08-27.yaml`) so several repositories can publish the same product and version without overwriting each other under `bundle/{product}/`. If the authoring repo cannot be resolved, the command warns and falls back to `{product}-{version}.yaml`, which can collide. Option-mode `--output` still uses the path you pass.
+Profile-mode and option-mode bundle files are named `{repo}-{product}-{version}.yaml` (for example `kibana-cloud-serverless-2026-08-27.yaml` and `elasticsearch-cloud-serverless-2026-08-27.yaml`) so several repositories can publish the same product and version without overwriting each other under `bundle/{product}/`. In option mode, an explicit `--output` file path (a path ending in `.yml` or `.yaml`) is used as-is. When `--output` is omitted, that `{repo}-{product}-{version}.yaml` name is written under `bundle.output_directory`. When `--output` is a directory (any path that does not end in `.yml` or `.yaml`), the file is written in that directory. If the authoring repo cannot be resolved, the command warns and falls back to `{product}-{version}.yaml`, which can collide. If product or version cannot be resolved, the command warns and writes `changelog-bundle.yaml`.
:::{note}
Upload uses content-hash–based incremental transfer. Unchanged files are skipped. Re-running the same command is safe and idempotent.
diff --git a/docs/data/release-notes/configure-ref.md b/docs/data/release-notes/configure-ref.md
index abbeb1d45..702e95e6c 100644
--- a/docs/data/release-notes/configure-ref.md
+++ b/docs/data/release-notes/configure-ref.md
@@ -49,10 +49,10 @@ These settings are relevant to one or all of the `changelog bundle`, `changelog
| `bundle.branch` | Branch whose CDN changelog pool (`changelog/{org}/{repo}/{branch}/...`) entries are sourced from when bundling (default: `main`). Refer to [Entry sourcing](#bundle-entry-sourcing). |
| `bundle.directory` | Input directory containing changelog YAML files (default: `docs/changelog`). |
| `bundle.link_allow_repos` | List of `owner/repo` pairs whose PR/issue links are preserved. When set (including empty `[]`), links to unlisted repos become `# PRIVATE:` sentinels. |
-| `bundle.output_directory` | Output directory for bundled files (default: `docs/releases`). |
+| `bundle.output_directory` | Output directory for bundled files (default: `docs/releases`). Conventional `{repo}-{product}-{version}.yaml` names are written here in profile mode and in option mode when `--output` is omitted. Passing `--output` as a directory writes that same file name in the directory you specify instead. |
| `bundle.owner` | Default GitHub repository owner (for example, `elastic`). Also the org segment of uploaded changelog-entry keys (`changelog/{org}/{repo}/{branch}/...`) and CDN entry sourcing. |
| `bundle.release_dates` | When `true`, bundles include a `release-date` field (default: true). |
-| `bundle.repo` | Default GitHub repository name (for example, `elasticsearch`). Used by the `{changelog}` directive to generate correct PR and issue links, to scope uploaded changelog-entry keys (`changelog/{org}/{repo}/{branch}/...`) and CDN entry sourcing, and as the `{repo}` segment of profile-mode bundle file names (`{repo}-{product}-{version}.yaml`). Only needed when the product ID doesn't match the GitHub repository name (or to override the git remote). |
+| `bundle.repo` | Default GitHub repository name (for example, `elasticsearch`). Used by the `{changelog}` directive to generate correct PR and issue links, to scope uploaded changelog-entry keys (`changelog/{org}/{repo}/{branch}/...`) and CDN entry sourcing, and as the `{repo}` segment of bundle file names (`{repo}-{product}-{version}.yaml`). Only needed when the product ID doesn't match the GitHub repository name (or to override the git remote). |
| `bundle.use_local_changelogs` | When `true`, always source entries from the local folder and never from the CDN (default: `false`). Refer to [Entry sourcing](#bundle-entry-sourcing). |
:::
diff --git a/src/services/Elastic.Changelog/Bundling/BundleOutputNaming.cs b/src/services/Elastic.Changelog/Bundling/BundleOutputNaming.cs
index f65e6f13f..923736147 100644
--- a/src/services/Elastic.Changelog/Bundling/BundleOutputNaming.cs
+++ b/src/services/Elastic.Changelog/Bundling/BundleOutputNaming.cs
@@ -5,10 +5,11 @@
using System.IO.Abstractions;
using Elastic.Documentation.Configuration;
using Elastic.Documentation.Diagnostics;
+using Elastic.Documentation.ReleaseNotes;
namespace Elastic.Changelog.Bundling;
-/// Inputs for conventional profile-mode bundle file names.
+/// Inputs for conventional bundle file names (profile and option mode).
public readonly record struct BundleOutputNameRequest(
string Product,
string Version,
@@ -19,18 +20,75 @@ public readonly record struct BundleOutputNameRequest(
);
///
-/// Profile-mode bundle names: {repo}-{product}-{version}.yaml when an authoring repo
-/// resolves, else {product}-{version}.yaml with a warning.
+/// Bundle names: {repo}-{product}-{version}.yaml when an authoring repo
+/// resolves, else {product}-{version}.yaml with a warning. When product or version
+/// cannot be resolved, .
///
public static class BundleOutputNaming
{
public const string UnprefixedConvention = "{product}-{version}.yaml";
public const string PrefixedConvention = "{repo}-{product}-{version}.yaml";
+ public const string FallbackFileName = "changelog-bundle.yaml";
+
///
/// Resolves the conventional file name (basename only). Repo precedence:
/// --repo, profile repo, bundle.repo, git origin on github.com.
+ /// When or is missing, warns and returns
+ /// .
///
+ public static string ResolveFileNameOrFallback(IDiagnosticsCollector collector, IFileSystem fileSystem, BundleOutputNameRequest request)
+ {
+ if (string.IsNullOrWhiteSpace(request.Product) || string.IsNullOrWhiteSpace(request.Version))
+ {
+ collector.EmitWarning(
+ string.Empty,
+ "Could not resolve a product and version for the bundle file (pass --output-products or --input-products with a concrete target). " +
+ $"Using '{FallbackFileName}'."
+ );
+ return FallbackFileName;
+ }
+
+ return ResolveFileName(collector, fileSystem, request);
+ }
+
+ public static bool IsYamlFilePath(string? path)
+ {
+ if (string.IsNullOrWhiteSpace(path))
+ return false;
+
+ return path.EndsWith(".yaml", StringComparison.OrdinalIgnoreCase) || path.EndsWith(".yml", StringComparison.OrdinalIgnoreCase);
+ }
+
+ ///
+ /// Concrete version for option-mode naming: first non-wildcard target on
+ /// --output-products, then --input-products (kept as-is, including
+ /// calendar dates like 2026-08-27), then --release-version with a
+ /// leading v and pre-release suffix stripped. latest is ignored.
+ ///
+ public static string? ResolveVersion(
+ IReadOnlyList? outputProducts,
+ IReadOnlyList? inputProducts,
+ string? releaseVersion
+ )
+ {
+ foreach (var list in new[] { outputProducts, inputProducts })
+ {
+ if (list is null)
+ continue;
+ foreach (var p in list)
+ {
+ if (!string.IsNullOrWhiteSpace(p.Target) && p.Target != "*")
+ return p.Target;
+ }
+ }
+
+ if (string.IsNullOrWhiteSpace(releaseVersion) || releaseVersion.Equals("latest", StringComparison.OrdinalIgnoreCase))
+ return null;
+
+ return ChangelogTextUtilities.ExtractBaseVersion(releaseVersion);
+ }
+
public static string ResolveFileName(IDiagnosticsCollector collector, IFileSystem fileSystem, BundleOutputNameRequest request)
{
var repo = ResolveAuthoringRepo(fileSystem, request);
diff --git a/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs b/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs
index 57a6245a4..9a790fb18 100644
--- a/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs
+++ b/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs
@@ -55,6 +55,12 @@ public record BundleChangelogsArguments
public string? Owner { get; init; }
public string? Repo { get; init; }
+ ///
+ /// GitHub release tag from CLI --release-version, used only for option-mode default
+ /// file naming when product targets are not set. Filter PRs are already expanded by the CLI.
+ ///
+ public string? ReleaseVersion { get; init; }
+
///
/// Branch whose CDN changelog pool (changelog/{org}/{repo}/{branch}/…) entries are sourced from.
/// null = use config bundle.branch, then the default branch (main).
@@ -364,8 +370,7 @@ public async Task BundleChangelogs(IDiagnosticsCollector collector, Bundle
// Directory is resolved by ApplyConfigDefaults (never null at this point)
var directory = input.Directory!;
- // Determine output path
- var outputPath = input.Output ?? _fileSystem.Path.Join(directory, "changelog-bundle.yaml");
+ var outputPath = ResolveResolvedOutputPath(collector, input, config);
// Build filter criteria
var filterCriteria = BuildFilterCriteria(input, prsToMatch, issuesToMatch);
@@ -936,7 +941,7 @@ Cancel ctx
return false;
var directory = input.Directory!;
- var outputPath = input.Output ?? _fileSystem.Path.Join(directory, "changelog-bundle.yaml");
+ var outputPath = ResolveResolvedOutputPath(collector, input, config);
var candidates = sourcing.UseCdn
? await FetchCdnEntriesAsync(collector, owner, sourcing.Repo, sourcing.Branch, ctx)
@@ -1020,10 +1025,9 @@ private BundleChangelogsArguments ApplyConfigDefaults(BundleChangelogsArguments
if (config?.Bundle == null)
return input with { Directory = directory, LinkAllowRepos = null };
- // Apply output default when --output not specified: use bundle.output_directory if set
+ // File name is resolved later in ResolveResolvedOutputPath so option-mode can use the
+ // conventional {repo}-{product}-{version}.yaml name. Keep a directory --output as-is.
var output = input.Output;
- if (string.IsNullOrWhiteSpace(output) && !string.IsNullOrWhiteSpace(config.Bundle.OutputDirectory))
- output = _fileSystem.Path.Join(config.Bundle.OutputDirectory, "changelog-bundle.yaml").OptionalWindowsReplace();
// Apply repo/owner/branch: CLI takes precedence; fall back to bundle-level config defaults.
var repo = input.Repo ?? config.Bundle.Repo;
@@ -1124,12 +1128,11 @@ Cancel ctx
)
needsNetwork = true;
- // Resolve output path — mirrors ProcessProfile + ApplyConfigDefaults: the
- // {repo}-{product}-{version}.yaml convention when the profile's primary product and a
- // plain version argument resolve, else changelog-bundle.yaml.
- var outputPath = input.Output;
+ // Resolve output path — mirrors ProcessProfile (profile convention) and
+ // ResolveResolvedOutputPath (option-mode convention or changelog-bundle.yaml).
+ string? outputPath;
if (
- string.IsNullOrWhiteSpace(outputPath)
+ !BundleOutputNaming.IsYamlFilePath(input.Output)
&& profileDef != null
&& !string.IsNullOrWhiteSpace(input.ProfileArgument)
&& PlanVersionArgumentRegex().IsMatch(input.ProfileArgument)
@@ -1152,8 +1155,8 @@ Cancel ctx
);
outputPath = JoinProfileOutputPath(config?.Bundle?.OutputDirectory, input.OutputDirectory, config?.Bundle?.Directory, fileName);
}
- else if (string.IsNullOrWhiteSpace(outputPath) && config?.Bundle?.OutputDirectory != null)
- outputPath = _fileSystem.Path.Join(config.Bundle.OutputDirectory, "changelog-bundle.yaml").OptionalWindowsReplace();
+ else
+ outputPath = ResolveResolvedOutputPath(collector, input, config);
return new BundlePlanResult
{
@@ -1209,6 +1212,42 @@ Cancel ctx
return null;
}
+ ///
+ /// Explicit .yml/.yaml --output wins. A directory --output (or
+ /// omitted) joins the conventional name, or
+ /// when product/version cannot be resolved. Profile mode that already missed convention keeps
+ /// the fallback name without a second product/version warning.
+ ///
+ private string ResolveResolvedOutputPath(
+ IDiagnosticsCollector collector,
+ BundleChangelogsArguments input,
+ ChangelogConfiguration? config
+ )
+ {
+ if (BundleOutputNaming.IsYamlFilePath(input.Output))
+ return input.Output!.OptionalWindowsReplace();
+
+ var outputDir = !string.IsNullOrWhiteSpace(input.Output)
+ ? input.Output
+ : config?.Bundle?.OutputDirectory
+ ?? input.OutputDirectory
+ ?? input.Directory
+ ?? config?.Bundle?.Directory
+ ?? _fileSystem.Directory.GetCurrentDirectory();
+
+ if (!string.IsNullOrWhiteSpace(input.Profile))
+ return _fileSystem.Path.Join(outputDir, BundleOutputNaming.FallbackFileName).OptionalWindowsReplace();
+
+ var product = ResolvePrimaryProduct(null, input) ?? "";
+ var version = BundleOutputNaming.ResolveVersion(input.OutputProducts, input.InputProducts, input.ReleaseVersion) ?? "";
+ var fileName = BundleOutputNaming.ResolveFileNameOrFallback(
+ collector,
+ _fileSystem,
+ new BundleOutputNameRequest(product, version, input.Repo, null, config?.Bundle?.Repo, input.Config)
+ );
+ return _fileSystem.Path.Join(outputDir, fileName).OptionalWindowsReplace();
+ }
+
///
/// Resolution order: bundle.output_directory → input.OutputDirectory (programmatic override)
/// → bundle.directory → CWD.
diff --git a/src/tooling/docs-builder/Commands/ChangelogCommand.cs b/src/tooling/docs-builder/Commands/ChangelogCommand.cs
index b7b169011..870e4d420 100644
--- a/src/tooling/docs-builder/Commands/ChangelogCommand.cs
+++ b/src/tooling/docs-builder/Commands/ChangelogCommand.cs
@@ -756,7 +756,7 @@ public async Task Note(
/// Explicit release date for the bundle in YYYY-MM-DD format. Overrides auto-population behaviour. Mutually exclusive with --no-release-date. This option is not supported in profile-based commands; use option-based mode, or set bundle.release_dates in configuration to control auto-population.
/// Filter by products in format "product target lifecycle, ..." (for example, "cloud-serverless 2025-12-02 ga, cloud-serverless 2025-12-06 beta"). All three parts are required but can be wildcards (*). A non-wildcard target matches products[].versions (changelog note) or a legacy target; not supported when sourcing from the CDN. This option is not supported in profile-based commands. The equivalent configuration option is bundle.profiles.<name>.products.
/// 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. This option is not supported in profile-based commands. Pass a promotion report as the second or third positional argument instead, or set source: github_release on the profile.
- /// Output path for the bundled changelog (directory or .yml/.yaml file). Uses config bundle.output_directory or defaults to 'changelog-bundle.yaml' in the input directory. This option is not supported in profile-based commands, where bundle names are derived by convention as {repo}-{product}-{version}.yaml from the authoring repo and the profile's primary output product.
+ /// Output path for the bundled changelog. A .yml/.yaml file is used as-is. A directory, or omitting this option, writes {repo}-{product}-{version}.yaml (from --repo / bundle.repo / git origin, --output-products then --input-products, then --release-version). Warns and writes changelog-bundle.yaml if product or version cannot be resolved, or {product}-{version}.yaml if no repo resolves. Not supported in profile-based commands (same convention from the profile).
/// Explicitly set the products array in the output file in format "product target lifecycle, ...". This option is not supported in profile-based commands. The equivalent configuration option is bundle.profiles.<name>.output_products.
/// GitHub repository owner for PR/issue numbers or --release-version. Falls back to bundle.owner or "elastic". This option is not supported in profile-based commands. The equivalent configuration options are bundle.owner or bundle.profiles.<name>.owner.
/// Branch whose CDN changelog entry pool (changelog/{org}/{repo}/{branch}/...) is sourced from. Falls back to bundle.branch or "main". This option is not supported in profile-based commands. The equivalent configuration options are bundle.branch or bundle.profiles.<name>.branch.
@@ -1126,8 +1126,8 @@ public async Task Bundle(
return 1;
}
- // It's a directory path - append default filename
- processedOutput = Path.Join(output, "changelog-bundle.yaml");
+ // Directory: the service joins the conventional file name (or changelog-bundle.yaml).
+ processedOutput = output;
}
}
@@ -1143,7 +1143,10 @@ public async Task Bundle(
Files = allFiles.Count > 0 ? allFiles.ToArray() : null,
ForceLocal = forceLocal,
Directory = directory?.FullName,
+ InputProducts = inputProducts,
+ OutputProducts = outputProducts,
Repo = repo,
+ ReleaseVersion = releaseVersion,
Config = config?.FullName,
Description = description,
StartGitRef = startGitRef,
@@ -1209,6 +1212,7 @@ public async Task Bundle(
ForceLocal = forceLocal,
Owner = owner,
Repo = repo,
+ ReleaseVersion = releaseVersion,
Branch = branch,
Profile = profile,
ProfileArgument = profileArg,
diff --git a/tests/Elastic.Changelog.Tests/Changelogs/BundleOutputConventionTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/BundleOutputConventionTests.cs
index eaac5d9d8..b6a7b31d2 100644
--- a/tests/Elastic.Changelog.Tests/Changelogs/BundleOutputConventionTests.cs
+++ b/tests/Elastic.Changelog.Tests/Changelogs/BundleOutputConventionTests.cs
@@ -6,14 +6,16 @@
using Elastic.Changelog.Bundling;
using Elastic.Documentation.Configuration;
using Elastic.Documentation.Diagnostics;
+using Elastic.Documentation.Extensions;
namespace Elastic.Changelog.Tests.Changelogs;
///
/// Tests for the standardized bundle output naming (B2 — elastic/docs-builder#3774):
-/// explicit output: patterns are a hard error, names derive as
-/// {repo}-{product}-{version}.yaml when a repo resolves (else unprefixed with a warning),
-/// and two profiles colliding on the same conventional target are rejected.
+/// explicit profile output: patterns are a hard error, names derive as
+/// {repo}-{product}-{version}.yaml when a repo resolves (else unprefixed with a warning)
+/// in both profile and option mode, and two profiles colliding on the same conventional
+/// target are rejected.
///
public class BundleOutputConventionTests(ITestOutputHelper output) : ChangelogTestBase(output)
{
@@ -375,4 +377,185 @@ public async Task Plan_ProfileWithOutputPattern_FailsTheSameWay()
plan.Should().BeNull();
Collector.Diagnostics.Should().Contain(d => d.Severity == Severity.Error && d.Message.Contains("'output' is no longer supported"));
}
+
+ [Fact]
+ public async Task OptionMode_OutputProductsAndBundleRepo_WritesPrefixedName()
+ {
+ var configPath = await WriteConfig(
+ """
+ bundle:
+ directory: CHANGELOG_DIR
+ use_local_changelogs: true
+ repo: kibana
+ """
+ );
+
+ var input = new BundleChangelogsArguments
+ {
+ All = true,
+ Config = configPath,
+ OutputProducts = [new ProductArgument { Product = "cloud-serverless", Target = "2026-08-27" }]
+ };
+ var result = await Service().BundleChangelogs(Collector, input, TestContext.Current.CancellationToken);
+
+ result.Should().BeTrue(
+ $"Errors: {string.Join("; ", Collector.Diagnostics.Where(d => d.Severity == Severity.Error).Select(d => d.Message))}"
+ );
+ FileSystem
+ .File
+ .Exists(FileSystem.Path.Join(_changelogDir, "kibana-cloud-serverless-2026-08-27.yaml"))
+ .Should()
+ .BeTrue("option mode without --output uses the same repo-product-version convention as profile mode");
+ }
+
+ [Fact]
+ public async Task OptionMode_ExplicitYamlOutput_Unchanged()
+ {
+ var configPath = await WriteConfig(
+ """
+ bundle:
+ directory: CHANGELOG_DIR
+ use_local_changelogs: true
+ repo: kibana
+ """
+ );
+
+ var custom = FileSystem.Path.Join(_changelogDir, "custom.yaml");
+ var input = new BundleChangelogsArguments
+ {
+ All = true,
+ Config = configPath,
+ Output = custom,
+ OutputProducts = [new ProductArgument { Product = "cloud-serverless", Target = "2026-08-27" }]
+ };
+ var result = await Service().BundleChangelogs(Collector, input, TestContext.Current.CancellationToken);
+
+ result.Should().BeTrue(
+ $"Errors: {string.Join("; ", Collector.Diagnostics.Where(d => d.Severity == Severity.Error).Select(d => d.Message))}"
+ );
+ FileSystem.File.Exists(custom).Should().BeTrue("an explicit yaml --output path is used as-is");
+ FileSystem.File.Exists(FileSystem.Path.Join(_changelogDir, "kibana-cloud-serverless-2026-08-27.yaml")).Should().BeFalse();
+ }
+
+ [Fact]
+ public async Task OptionMode_DirectoryOutput_JoinsConventionalName()
+ {
+ var configPath = await WriteConfig(
+ """
+ bundle:
+ directory: CHANGELOG_DIR
+ use_local_changelogs: true
+ repo: kibana
+ """
+ );
+
+ var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString());
+ FileSystem.Directory.CreateDirectory(outputDir);
+
+ var input = new BundleChangelogsArguments
+ {
+ All = true,
+ Config = configPath,
+ Output = outputDir,
+ OutputProducts = [new ProductArgument { Product = "cloud-serverless", Target = "2026-08-27" }]
+ };
+ var result = await Service().BundleChangelogs(Collector, input, TestContext.Current.CancellationToken);
+
+ result.Should().BeTrue(
+ $"Errors: {string.Join("; ", Collector.Diagnostics.Where(d => d.Severity == Severity.Error).Select(d => d.Message))}"
+ );
+ FileSystem
+ .File
+ .Exists(FileSystem.Path.Join(outputDir, "kibana-cloud-serverless-2026-08-27.yaml"))
+ .Should()
+ .BeTrue("a directory --output joins the conventional file name");
+ }
+
+ [Fact]
+ public async Task OptionMode_MissingProductAndVersion_WarnsAndUsesFallbackName()
+ {
+ var configPath = await WriteConfig(
+ """
+ bundle:
+ directory: CHANGELOG_DIR
+ use_local_changelogs: true
+ repo: kibana
+ """
+ );
+
+ var input = new BundleChangelogsArguments { All = true, Config = configPath };
+ var result = await Service().BundleChangelogs(Collector, input, TestContext.Current.CancellationToken);
+
+ result.Should().BeTrue(
+ $"Errors: {string.Join("; ", Collector.Diagnostics.Where(d => d.Severity == Severity.Error).Select(d => d.Message))}"
+ );
+ FileSystem
+ .File
+ .Exists(FileSystem.Path.Join(_changelogDir, BundleOutputNaming.FallbackFileName))
+ .Should()
+ .BeTrue("option mode without a concrete product and version keeps the legacy fallback file name");
+ Collector
+ .Diagnostics
+ .Should()
+ .Contain(d => d.Severity == Severity.Warning && d.Message.Contains("Could not resolve a product and version"));
+ }
+
+ [Fact]
+ public async Task OptionMode_PlanMatchesRunPath()
+ {
+ var configPath = await WriteConfig(
+ """
+ bundle:
+ directory: CHANGELOG_DIR
+ use_local_changelogs: true
+ output_directory: CHANGELOG_DIR
+ repo: kibana
+ """
+ );
+
+ var input = new BundleChangelogsArguments
+ {
+ All = true,
+ Config = configPath,
+ OutputProducts = [new ProductArgument { Product = "cloud-serverless", Target = "2026-08-27" }]
+ };
+
+ var plan = await Service().PlanBundleAsync(Collector, input, hasReleaseVersion: false, TestContext.Current.CancellationToken);
+ plan.Should().NotBeNull();
+ plan!
+ .OutputPath
+ .Should()
+ .Be(FileSystem.Path.Join(_changelogDir, "kibana-cloud-serverless-2026-08-27.yaml").OptionalWindowsReplace());
+
+ var result = await Service().BundleChangelogs(Collector, input, TestContext.Current.CancellationToken);
+ result.Should().BeTrue(
+ $"Errors: {string.Join("; ", Collector.Diagnostics.Where(d => d.Severity == Severity.Error).Select(d => d.Message))}"
+ );
+ FileSystem.File.Exists(plan.OutputPath).Should().BeTrue("--plan output_path matches the file bundle writes");
+ }
+
+ [Fact]
+ public void ResolveVersion_PrefersOutputProductsThenInputThenReleaseTag()
+ {
+ BundleOutputNaming
+ .ResolveVersion(
+ [new ProductArgument { Product = "cloud-serverless", Target = "2026-08-27" }],
+ [new ProductArgument { Product = "elasticsearch", Target = "9.3.0" }],
+ "v9.2.0"
+ )
+ .Should()
+ .Be("2026-08-27");
+
+ BundleOutputNaming
+ .ResolveVersion(null, [new ProductArgument { Product = "elasticsearch", Target = "9.3.0" }], "v9.2.0")
+ .Should()
+ .Be("9.3.0");
+
+ BundleOutputNaming
+ .ResolveVersion(null, [new ProductArgument { Product = "elasticsearch", Target = "*" }], "v9.2.0-beta.1")
+ .Should()
+ .Be("9.2.0");
+
+ BundleOutputNaming.ResolveVersion(null, null, "latest").Should().BeNull();
+ }
}
diff --git a/tests/Elastic.Changelog.Tests/Changelogs/BundlePlanTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/BundlePlanTests.cs
index b4dc9d64f..8dfdcef4a 100644
--- a/tests/Elastic.Changelog.Tests/Changelogs/BundlePlanTests.cs
+++ b/tests/Elastic.Changelog.Tests/Changelogs/BundlePlanTests.cs
@@ -243,6 +243,31 @@ public async Task Plan_NoOutput_FallsBackToConfigOutputDirectory()
result.OutputPath.Should().EndWith(FileSystem.Path.Join("docs", "releases", "changelog-bundle.yaml").OptionalWindowsReplace());
}
+ [Fact]
+ public async Task Plan_OptionMode_OutputProducts_UsesConventionalName()
+ {
+ var configContent = """
+ bundle:
+ output_directory: docs/releases
+ repo: kibana
+ """;
+ var configPath = await CreateConfigAsync(configContent);
+
+ var input = new BundleChangelogsArguments
+ {
+ Config = configPath,
+ OutputProducts = [new ProductArgument { Product = "cloud-serverless", Target = "2026-08-27" }]
+ };
+
+ var result = await Service.PlanBundleAsync(Collector, input, hasReleaseVersion: false, TestContext.Current.CancellationToken);
+
+ result.Should().NotBeNull();
+ result
+ .OutputPath
+ .Should()
+ .EndWith(FileSystem.Path.Join("docs", "releases", "kibana-cloud-serverless-2026-08-27.yaml").OptionalWindowsReplace());
+ }
+
[Fact]
public async Task Plan_ProfileNotFound_ReturnsResultWithNeedsNetworkFalse()
{