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() {