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
7 changes: 4 additions & 3 deletions config/changelog.example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -260,15 +260,16 @@ bundle:
# When used with 'changelog remove', only the 'products' field is applied.
# The 'output_products', 'repo', 'owner', and 'hide_features' fields are
# bundle-specific and are ignored for removal.
# Bundle file names are derived by convention as '{product}-{version}.yaml' from the
# profile's primary output product; setting an explicit 'output' pattern is an error.
# Bundle file names are derived by convention as '{repo}-{product}-{version}.yaml' from the
# authoring repo and the profile's primary output product; setting an explicit 'output'
# pattern is an error. If no repo can be resolved, the name falls back to '{product}-{version}.yaml'.
profiles:
# Example: Elasticsearch release profile (filter by changelog fields)
# elasticsearch-release:
# # Filter: which input changelogs to include ({version} and {lifecycle} are substituted at runtime)
# products: "elasticsearch {version} {lifecycle}"
# # The bundle's products metadata; also determines the conventional output
# # file name ({product}-{version}.yaml from the first product).
# # file name ({repo}-{product}-{version}.yaml from the authoring repo and the first product).
# output_products: "elasticsearch {version}"
# # Optional: profile-specific description (overrides bundle.description)
# # description: |
Expand Down
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 {product}-{version}.yaml from the profile\u0027s primary output product."
"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."
},
{
"role": "flag",
Expand Down
8 changes: 5 additions & 3 deletions docs/cli/changelog/cmd-bundle.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ The second positional argument accepts:
- A plain-text URL list file (one fully-qualified GitHub PR or issue URL per line)
- A plain-text path list file (one changelog YAML path per line, ending in `.yaml` or `.yml`)

When your profile uses `{version}` in its `output_products` pattern (or you want the conventional `{product}-{version}.yaml` bundle name) and you also want to filter by a report or list file, pass both arguments (version first, then the filter file).
When your profile uses `{version}` in its `output_products` pattern (or you want the conventional `{repo}-{product}-{version}.yaml` bundle name) and you also want to filter by a report or list file, pass both arguments (version first, then the filter file).

Example profile in `changelog.yml`:

Expand All @@ -33,9 +33,11 @@ bundle:
profiles:
elasticsearch-release:
output_products: "elasticsearch {version}"
serverless-release:
output_products: "cloud-serverless {version}"
```

The bundle's file name is derived by convention as `{product}-{version}.yaml` from the profile's primary output product and the version argument (for example, `docs/releases/elasticsearch-9.2.0.yaml`). Setting an explicit `output` pattern on a profile is a hard error, and no two profiles may share a primary output product — they would collide on the same conventional target.
The bundle's file name is derived by convention as `{repo}-{product}-{version}.yaml` from the authoring repository (`--repo`, then the profile's `repo`, then `bundle.repo`, then the git `origin`), the profile's primary output product, and the version argument. For example, `docs-builder changelog bundle serverless-release 2026-08-31` writes `docs/releases/elasticsearch-cloud-serverless-2026-08-31.yaml`. If no repository can be resolved, the command warns and falls back to `{product}-{version}.yaml`. Setting an explicit `output` pattern on a profile is a hard error, and no two profiles may share a primary output product — they would collide on the same conventional target.

## Option-based mode

Expand Down Expand Up @@ -96,7 +98,7 @@ Both refs are always required together — the start ref is never inferred from
- **PRs whose metadata cannot be fetched are reported as missing** with a warning.
4. Records the end ref in the bundle output as the `git_ref` metadata field.

Commit-range mode works in both profile-based and option-based commands and is mutually exclusive with every other filter. In profile-based commands the profile contributes output metadata only (`output_products`, `repo`, `owner`, `rules`, and so on) — it must not set a `products` pattern or `source: github_release`. The bundle name follows the `{product}-{version}.yaml` convention.
Commit-range mode works in both profile-based and option-based commands and is mutually exclusive with every other filter. In profile-based commands the profile contributes output metadata only (`output_products`, `repo`, `owner`, `rules`, and so on) — it must not set a `products` pattern or `source: github_release`. The bundle name follows the `{repo}-{product}-{version}.yaml` convention.

Re-running the same range produces the same bundle content; bundling never overwrites changelog entries.
Commit-range mode does not automatically add changelog notes whose `products[].versions` match `output_products`. Use `--files` or a path list if those files must be in the bundle.
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).

When several repositories publish bundles for the same shared product (for example `cloud-serverless`), use a `{repo}-{dateOrVersion}.yaml` bundle filename convention so they don't overwrite each other under `bundle/{product}/`.
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.

:::{note}
Upload uses content-hash–based incremental transfer. Unchanged files are skipped. Re-running the same command is safe and idempotent.
Expand Down
6 changes: 3 additions & 3 deletions docs/data/release-notes/bundle.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ bundle:

1. The directory that contains changelog files.
2. The directory that contains changelog bundles.
3. The bundle's product metadata, which affects the rules that are applied and the product and version titles that ultimately appear in the documentation. If omitted, it's derived from all the changelogs in the bundle. The first product also determines the bundle's file name, which is derived by convention as `{product}-{version}.yaml` under `output_directory`.
3. The bundle's product metadata, which affects the rules that are applied and the product and version titles that ultimately appear in the documentation. If omitted, it's derived from all the changelogs in the bundle. The authoring repo (`bundle.repo` here) and the first product also determine the bundle's file name, which is derived by convention as `{repo}-{product}-{version}.yaml` under `output_directory`.

### Bundle by GitHub releases [profile-gh-release]

Expand Down Expand Up @@ -126,7 +126,7 @@ bundle:
```

1. The authoring repository whose commit range is resolved and whose entry pool is consulted.
2. Also applied to entries synthesized from PR metadata when the PR's labels map to no product. The bundle is named `{product}-{version}.yaml` by convention.
2. Also applied to entries synthesized from PR metadata when the PR's labels map to no product. The bundle is named `{repo}-{product}-{version}.yaml` by convention (here `my-service-cloud-serverless-2026-08-13.yaml`).

```sh
docs-builder changelog bundle serverless-release 2026-08-13 \
Expand Down Expand Up @@ -171,7 +171,7 @@ bundle:

1. This profile collects all changelogs from the `directory`.
2. This profile collects any changelogs that have `product: cloud-serverless`, any lifecycle, and the date partially specified in the command. The date pattern matches files that declare `products[].versions` (or a legacy `target`), typically files created with `changelog note`. It does not match PR-linked changelogs that omit `versions`.
3. This profile collects any changelogs that have `product: kibana`, `lifecycle: ga`, and the version specified in the command. Like the date pattern, a concrete version only matches files that declare `products[].versions` (or a legacy `target`). No two profiles may target the same primary product — they would collide on the same conventional `{product}-{version}.yaml` bundle name.
3. This profile collects any changelogs that have `product: kibana`, `lifecycle: ga`, and the version specified in the command. Like the date pattern, a concrete version only matches files that declare `products[].versions` (or a legacy `target`). No two profiles may target the same primary product — they would collide on the same conventional `{repo}-{product}-{version}.yaml` bundle name.
4. In this case, the lifecycle is inferred from the version specified in the command. For example, if the version is `9.2.0-beta.1` the lifecycle is `beta`. ISO date arguments (for example, `2026-07-21`) derive `ga`. Refer to [](/cli/changelog/bundle.md#lifecycle-inference). A concrete `{version}` in `products` has the same `versions`/`target` matching rule as the previous examples.

For date-based and semver profiles, lifecycle is controlled only in the profile YAML: omit it from the pattern, use `{lifecycle}` to derive it, or hardcode `ga`, `beta`, or `preview`. Non-`ga` date-based releases are exceptional and should hardcode the lifecycle.
Expand Down
7 changes: 4 additions & 3 deletions docs/data/release-notes/configure-ref.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ These settings are relevant to one or all of the `changelog bundle`, `changelog
| `bundle.output_directory` | Output directory for bundled files (default: `docs/releases`). |
| `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, and to scope uploaded changelog-entry keys (`changelog/{org}/{repo}/{branch}/...`) and CDN entry sourcing. 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 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.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 Expand Up @@ -124,9 +124,9 @@ These settings are located in the `bundle.profiles.<name>` section of the config
: When the bundle is rendered, entries with matching `feature-id` values are commented out.

`output`
: Removed. Bundle output names are derived by convention as `{product}-{version}.yaml` from the profile's primary output product (the first product in `output_products`, or `products`) and the version argument. Setting `output` on any profile is a hard error at bundle time; remove the field.
: Removed. Bundle output names are derived by convention as `{repo}-{product}-{version}.yaml` from the authoring repository (`--repo`, then the profile's `repo`, then `bundle.repo`, then the git `origin`), the profile's primary output product (the first product in `output_products`, or `products`), and the version argument. If no repository can be resolved, the command warns and falls back to `{product}-{version}.yaml`. Setting `output` on any profile is a hard error at bundle time; remove the field.
: When no primary product or version resolves (for example, a promotion-report invocation without a version argument), the output path falls back in order to: `bundle.output_directory/changelog-bundle.yaml` (if `bundle.output_directory` is configured), then `changelog-bundle.yaml` in the input directory.
: No two profiles in the same configuration may share a primary output product — they would resolve to the same `{product}-{version}.yaml` target for any given version, which is also a hard error.
: No two profiles in the same configuration may share a primary output product — they would resolve to the same `{repo}-{product}-{version}.yaml` target for any given version, which is also a hard error.

`output_products`
: The bundle's `products` metadata, which affects the bundle rules that are applied and the product and version titles that ultimately appear in documentation.
Expand Down Expand Up @@ -155,6 +155,7 @@ These settings are located in the `bundle.profiles.<name>` section of the config
`repo`
: Overrides [bundle.repo](#bundle-basic).
: Required when `source: github_release` is used and `bundle.repo` is not set.
: Also the `{repo}` segment of the conventional bundle file name.

`source`
: Derive the list of changelogs from the specified source.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,12 +87,12 @@ public record BundleProfile

/// <summary>
/// Legacy output filename pattern. No longer supported: bundle output names are derived by
/// convention as <c>{product}-{version}.yaml</c> from the profile's primary output product
/// (elastic/docs-builder#3774). Any profile setting this is a hard error at bundle time; the
/// field remains parseable for one release cycle so authors get an actionable error rather
/// than a YAML parse failure.
/// convention as <c>{repo}-{product}-{version}.yaml</c> from the authoring repo and the profile's
/// primary output product (elastic/docs-builder#3774). Any profile setting this is a hard error
/// at bundle time; the field remains parseable for one release cycle so authors get an actionable
/// error rather than a YAML parse failure.
/// </summary>
[Obsolete("No longer supported: bundle output names are derived by convention as '{product}-{version}.yaml' from the profile's output_products. Setting 'output' is a hard error at bundle time.")]
[Obsolete("No longer supported: bundle output names are derived by convention as '{repo}-{product}-{version}.yaml' from the authoring repo and the profile's output_products. Setting 'output' is a hard error at bundle time.")]
public string? Output { get; init; }

/// <summary>
Expand Down
96 changes: 96 additions & 0 deletions src/services/Elastic.Changelog/Bundling/BundleOutputNaming.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
// Licensed to Elasticsearch B.V under one or more agreements.
// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
// See the LICENSE file in the project root for more information

using System.IO.Abstractions;
using Elastic.Documentation.Configuration;
using Elastic.Documentation.Diagnostics;

namespace Elastic.Changelog.Bundling;

/// <summary>Inputs for conventional profile-mode bundle file names.</summary>
public readonly record struct BundleOutputNameRequest(
string Product,
string Version,
string? CliRepo,
string? ProfileRepo,
string? BundleRepo,
string? ConfigPath
);

/// <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.
/// </summary>
public static class BundleOutputNaming
{
public const string UnprefixedConvention = "{product}-{version}.yaml";
public const string PrefixedConvention = "{repo}-{product}-{version}.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.
/// </summary>
public static string ResolveFileName(IDiagnosticsCollector collector, IFileSystem fileSystem, BundleOutputNameRequest request)
{
var repo = ResolveAuthoringRepo(fileSystem, request);
if (!string.IsNullOrWhiteSpace(repo))
return $"{repo}-{request.Product}-{request.Version}.yaml";

collector.EmitWarning(
string.Empty,
"Could not resolve a repository name for the bundle file (set bundle.repo, pass --repo, or run from a git checkout with a github.com origin). " +
$"Using '{UnprefixedConvention}'; two repositories publishing the same product and version may overwrite each other."
);
return $"{request.Product}-{request.Version}.yaml";
}

internal static string? ResolveAuthoringRepo(IFileSystem fileSystem, BundleOutputNameRequest request)
{
var configured = FirstNonEmpty(request.CliRepo, request.ProfileRepo, request.BundleRepo);
var normalized = ChangelogRepoOwnerResolver.NormalizeRepo(configured);
return !string.IsNullOrWhiteSpace(normalized) ? normalized : TryGitOriginRepo(fileSystem, request.ConfigPath);
}

private static string? FirstNonEmpty(params string?[] values)
{
foreach (var value in values)
{
if (!string.IsNullOrWhiteSpace(value))
return value;
}

return null;
}

private static string? TryGitOriginRepo(IFileSystem fileSystem, string? configPath)
{
string? start = null;
if (!string.IsNullOrWhiteSpace(configPath))
start = fileSystem.Path.GetDirectoryName(configPath);
start ??= fileSystem.Directory.GetCurrentDirectory();
if (string.IsNullOrWhiteSpace(start))
return null;

var current = fileSystem.DirectoryInfo.New(start);
for (var depth = 0; depth < 16 && current != null; depth++)
{
if (
GitRemoteConfigurationReader.TryReadOriginUrl(fileSystem, current.FullName, out var url)
&& GitHubRemoteParser.TryParseGitHubComOwnerRepo(url, out _, out var repo)
)
{
var normalized = ChangelogRepoOwnerResolver.NormalizeRepo(repo);
if (!string.IsNullOrWhiteSpace(normalized))
return normalized;
}

var parent = current.Parent;
if (parent is null || string.Equals(parent.FullName, current.FullName, StringComparison.Ordinal))
break;
current = parent;
}

return null;
}
}
Loading
Loading