Skip to content
Open
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
2 changes: 1 addition & 1 deletion docs/cli-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion docs/cli/changelog/cmd-upload.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions docs/data/release-notes/configure-ref.md
Original file line number Diff line number Diff line change
Expand Up @@ -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). |

:::
Expand Down
64 changes: 61 additions & 3 deletions src/services/Elastic.Changelog/Bundling/BundleOutputNaming.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,11 @@
using System.IO.Abstractions;
using Elastic.Documentation.Configuration;
using Elastic.Documentation.Diagnostics;
using Elastic.Documentation.ReleaseNotes;

namespace Elastic.Changelog.Bundling;

/// <summary>Inputs for conventional profile-mode bundle file names.</summary>
/// <summary>Inputs for conventional bundle file names (profile and option mode).</summary>
public readonly record struct BundleOutputNameRequest(
string Product,
string Version,
Expand All @@ -19,18 +20,75 @@ public readonly record struct BundleOutputNameRequest(
);

/// <summary>
/// Profile-mode bundle names: <c>{repo}-{product}-{version}.yaml</c> when an authoring repo
/// resolves, else <c>{product}-{version}.yaml</c> with a warning.
/// Bundle names: <c>{repo}-{product}-{version}.yaml</c> when an authoring repo
/// resolves, else <c>{product}-{version}.yaml</c> with a warning. When product or version
/// cannot be resolved, <see cref="FallbackFileName"/>.
/// </summary>
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";

/// <summary>
/// Resolves the conventional file name (basename only). Repo precedence:
/// <c>--repo</c>, profile <c>repo</c>, <c>bundle.repo</c>, git <c>origin</c> on github.com.
/// When <paramref name="product"/> or <paramref name="version"/> is missing, warns and returns
/// <see cref="FallbackFileName"/>.
/// </summary>
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);
}

/// <summary>
/// Concrete version for option-mode naming: first non-wildcard target on
/// <c>--output-products</c>, then <c>--input-products</c> (kept as-is, including
/// calendar dates like <c>2026-08-27</c>), then <c>--release-version</c> with a
/// leading <c>v</c> and pre-release suffix stripped. <c>latest</c> is ignored.
/// </summary>
public static string? ResolveVersion(
IReadOnlyList<ProductArgument>? outputProducts,
IReadOnlyList<ProductArgument>? 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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,12 @@ public record BundleChangelogsArguments
public string? Owner { get; init; }
public string? Repo { get; init; }

/// <summary>
/// GitHub release tag from CLI <c>--release-version</c>, used only for option-mode default
/// file naming when product targets are not set. Filter PRs are already expanded by the CLI.
/// </summary>
public string? ReleaseVersion { get; init; }

/// <summary>
/// Branch whose CDN changelog pool (<c>changelog/{org}/{repo}/{branch}/…</c>) entries are sourced from.
/// null = use config <c>bundle.branch</c>, then the default branch (<c>main</c>).
Expand Down Expand Up @@ -364,8 +370,7 @@ public async Task<bool> 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);
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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)
Expand All @@ -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
{
Expand Down Expand Up @@ -1209,6 +1212,42 @@ Cancel ctx
return null;
}

/// <summary>
/// Explicit <c>.yml</c>/<c>.yaml</c> <c>--output</c> wins. A directory <c>--output</c> (or
/// omitted) joins the conventional name, or <see cref="BundleOutputNaming.FallbackFileName"/>
/// when product/version cannot be resolved. Profile mode that already missed convention keeps
/// the fallback name without a second product/version warning.
/// </summary>
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();
}

/// <summary>
/// Resolution order: bundle.output_directory → input.OutputDirectory (programmatic override)
/// → bundle.directory → CWD.
Expand Down
10 changes: 7 additions & 3 deletions src/tooling/docs-builder/Commands/ChangelogCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -756,7 +756,7 @@ public async Task<int> Note(
/// <param name="releaseDate">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 <c>bundle.release_dates</c> in configuration to control auto-population.</param>
/// <param name="inputProducts">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 <c>bundle.profiles.&lt;name&gt;.products</c>.</param>
/// <param name="issues">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 <c>source: github_release</c> on the profile.</param>
/// <param name="output">Output path for the bundled changelog (directory or .yml/.yaml file). Uses config <c>bundle.output_directory</c> 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 <c>{repo}-{product}-{version}.yaml</c> from the authoring repo and the profile's primary output product.</param>
/// <param name="output">Output path for the bundled changelog. A .yml/.yaml file is used as-is. A directory, or omitting this option, writes <c>{repo}-{product}-{version}.yaml</c> (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 <c>{product}-{version}.yaml</c> if no repo resolves. Not supported in profile-based commands (same convention from the profile).</param>
/// <param name="outputProducts">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 <c>bundle.profiles.&lt;name&gt;.output_products</c>.</param>
/// <param name="owner">GitHub repository owner for PR/issue numbers or --release-version. Falls back to <c>bundle.owner</c> or "elastic". This option is not supported in profile-based commands. The equivalent configuration options are <c>bundle.owner</c> or <c>bundle.profiles.&lt;name&gt;.owner</c>.</param>
/// <param name="branch">Branch whose CDN changelog entry pool (<c>changelog/{org}/{repo}/{branch}/...</c>) is sourced from. Falls back to <c>bundle.branch</c> or "main". This option is not supported in profile-based commands. The equivalent configuration options are <c>bundle.branch</c> or <c>bundle.profiles.&lt;name&gt;.branch</c>.</param>
Expand Down Expand Up @@ -1126,8 +1126,8 @@ public async Task<int> 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;
}
}

Expand All @@ -1143,7 +1143,10 @@ public async Task<int> 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,
Expand Down Expand Up @@ -1209,6 +1212,7 @@ public async Task<int> Bundle(
ForceLocal = forceLocal,
Owner = owner,
Repo = repo,
ReleaseVersion = releaseVersion,
Branch = branch,
Profile = profile,
ProfileArgument = profileArg,
Expand Down
Loading
Loading