From 1a7e92b49083824a9ab29ef4ae04b2af9e26870c Mon Sep 17 00:00:00 2001 From: lcawl Date: Mon, 31 Aug 2026 12:14:17 -0500 Subject: [PATCH] Repo-prefixed changelog bundle output names --- config/changelog.example.yml | 7 +- docs/cli-schema.json | 2 +- docs/cli/changelog/cmd-bundle.md | 8 +- docs/cli/changelog/cmd-upload.md | 2 +- docs/data/release-notes/bundle.md | 6 +- docs/data/release-notes/configure-ref.md | 7 +- .../Changelog/BundleConfiguration.cs | 10 +- .../Bundling/BundleOutputNaming.cs | 96 +++++++++ .../Bundling/ChangelogBundlingService.cs | 77 ++++--- .../docs-builder/Commands/ChangelogCommand.cs | 2 +- .../Changelogs/BundleGitRefTests.cs | 8 +- .../Changelogs/BundleOutputConventionTests.cs | 200 +++++++++++++++++- .../Changelogs/BundlePlanTests.cs | 20 +- .../BundleProfileGitHubReleaseTests.cs | 15 +- .../Changelogs/CloudProfileFixtureTests.cs | 2 +- 15 files changed, 394 insertions(+), 68 deletions(-) create mode 100644 src/services/Elastic.Changelog/Bundling/BundleOutputNaming.cs diff --git a/config/changelog.example.yml b/config/changelog.example.yml index f097ac697..00c0aac89 100644 --- a/config/changelog.example.yml +++ b/config/changelog.example.yml @@ -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: | diff --git a/docs/cli-schema.json b/docs/cli-schema.json index 5ff7b2776..96d214901 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 {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", diff --git a/docs/cli/changelog/cmd-bundle.md b/docs/cli/changelog/cmd-bundle.md index d7541fb05..7e220ceb9 100644 --- a/docs/cli/changelog/cmd-bundle.md +++ b/docs/cli/changelog/cmd-bundle.md @@ -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`: @@ -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 @@ -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. diff --git a/docs/cli/changelog/cmd-upload.md b/docs/cli/changelog/cmd-upload.md index a7ab915eb..e3f63f1f9 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). -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. diff --git a/docs/data/release-notes/bundle.md b/docs/data/release-notes/bundle.md index 488343d4b..335d0c226 100644 --- a/docs/data/release-notes/bundle.md +++ b/docs/data/release-notes/bundle.md @@ -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] @@ -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 \ @@ -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. diff --git a/docs/data/release-notes/configure-ref.md b/docs/data/release-notes/configure-ref.md index 5006858b7..abbeb1d45 100644 --- a/docs/data/release-notes/configure-ref.md +++ b/docs/data/release-notes/configure-ref.md @@ -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). | ::: @@ -124,9 +124,9 @@ These settings are located in the `bundle.profiles.` 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. @@ -155,6 +155,7 @@ These settings are located in the `bundle.profiles.` 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. diff --git a/src/Elastic.Documentation.Configuration/Changelog/BundleConfiguration.cs b/src/Elastic.Documentation.Configuration/Changelog/BundleConfiguration.cs index 2f127afab..8378bb27c 100644 --- a/src/Elastic.Documentation.Configuration/Changelog/BundleConfiguration.cs +++ b/src/Elastic.Documentation.Configuration/Changelog/BundleConfiguration.cs @@ -87,12 +87,12 @@ public record BundleProfile /// /// Legacy output filename pattern. No longer supported: bundle output names are derived by - /// convention as {product}-{version}.yaml 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 {repo}-{product}-{version}.yaml 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. /// - [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; } /// diff --git a/src/services/Elastic.Changelog/Bundling/BundleOutputNaming.cs b/src/services/Elastic.Changelog/Bundling/BundleOutputNaming.cs new file mode 100644 index 000000000..f65e6f13f --- /dev/null +++ b/src/services/Elastic.Changelog/Bundling/BundleOutputNaming.cs @@ -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; + +/// Inputs for conventional profile-mode bundle file names. +public readonly record struct BundleOutputNameRequest( + string Product, + string Version, + string? CliRepo, + string? ProfileRepo, + string? BundleRepo, + string? ConfigPath +); + +/// +/// Profile-mode bundle names: {repo}-{product}-{version}.yaml when an authoring repo +/// resolves, else {product}-{version}.yaml with a warning. +/// +public static class BundleOutputNaming +{ + public const string UnprefixedConvention = "{product}-{version}.yaml"; + public const string PrefixedConvention = "{repo}-{product}-{version}.yaml"; + + /// + /// Resolves the conventional file name (basename only). Repo precedence: + /// --repo, profile repo, bundle.repo, git origin on github.com. + /// + 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; + } +} diff --git a/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs b/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs index ae8a22e63..57a6245a4 100644 --- a/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs +++ b/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs @@ -674,20 +674,25 @@ Cancel ctx // For all other profile types, infer it from the base version string. var resolvedLifecycle = filterResult.Lifecycle ?? VersionLifecycleInference.InferLifecycle(filterResult.Version); - // Bundle output names follow the standardized {product}-{version}.yaml convention (B2 — - // elastic/docs-builder#3774), derived from the profile's primary output product and the - // version argument. When either is unavailable (e.g. a report/list invocation without a - // version) the default changelog-bundle.yaml naming applies downstream. + // Bundle output names follow {repo}-{product}-{version}.yaml when an authoring repo + // resolves (B2 — elastic/docs-builder#3774). When product or version is unavailable + // (e.g. a report/list invocation without a version) changelog-bundle.yaml applies downstream. var primaryProduct = ResolvePrimaryProduct(profile, input); if (!string.IsNullOrWhiteSpace(primaryProduct) && filterResult.Version != "unknown") { - // Resolution order: bundle.output_directory → input.OutputDirectory (programmatic override) - // → bundle.directory → CWD - var outputDir = config.Bundle.OutputDirectory - ?? input.OutputDirectory - ?? config.Bundle.Directory - ?? _fileSystem.Directory.GetCurrentDirectory(); - outputPath = _fileSystem.Path.Join(outputDir, $"{primaryProduct}-{filterResult.Version}.yaml").OptionalWindowsReplace(); + var fileName = BundleOutputNaming.ResolveFileName( + collector, + _fileSystem, + new BundleOutputNameRequest( + primaryProduct, + filterResult.Version, + input.Repo, + profile.Repo, + config.Bundle.Repo, + input.Config + ) + ); + outputPath = JoinProfileOutputPath(config.Bundle.OutputDirectory, input.OutputDirectory, config.Bundle.Directory, fileName); } // Parse output_products pattern with version/lifecycle substitution @@ -1119,9 +1124,9 @@ Cancel ctx ) needsNetwork = true; - // Resolve output path — mirrors the logic in ProcessProfile + ApplyConfigDefaults: the - // standardized {product}-{version}.yaml convention when the profile's primary product and a - // plain version argument resolve, else the changelog-bundle.yaml default. + // 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; if ( string.IsNullOrWhiteSpace(outputPath) @@ -1140,13 +1145,12 @@ Cancel ctx var planVersion = string.Equals(profileDef.Source, "github_release", StringComparison.OrdinalIgnoreCase) ? ChangelogTextUtilities.ExtractBaseVersion(input.ProfileArgument) : input.ProfileArgument; - // Same precedence as ProcessProfile: config.Bundle.OutputDirectory > input.OutputDirectory - // (programmatic override, e.g. a CI-provided --output-directory) > config.Bundle.Directory > cwd. - var outputDir = config?.Bundle?.OutputDirectory - ?? input.OutputDirectory - ?? config?.Bundle?.Directory - ?? _fileSystem.Directory.GetCurrentDirectory(); - outputPath = _fileSystem.Path.Join(outputDir, $"{primaryProduct}-{planVersion}.yaml").OptionalWindowsReplace(); + var fileName = BundleOutputNaming.ResolveFileName( + collector, + _fileSystem, + new BundleOutputNameRequest(primaryProduct, planVersion, input.Repo, profileDef.Repo, config?.Bundle?.Repo, input.Config) + ); + 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(); @@ -1183,7 +1187,7 @@ Cancel ctx /// /// The first concrete (non-wildcard) product that scopes the bundle, used for the conventional - /// {product}-{version}.yaml name and the bundle's CDN URL. + /// file name and the bundle's CDN URL. /// From the profile output_products/products pattern, else the first explicit product argument. /// private static string? ResolvePrimaryProduct(BundleProfile? profileDef, BundleChangelogsArguments input) @@ -1205,6 +1209,21 @@ Cancel ctx return null; } + /// + /// Resolution order: bundle.output_directory → input.OutputDirectory (programmatic override) + /// → bundle.directory → CWD. + /// + private string JoinProfileOutputPath( + string? configOutputDirectory, + string? inputOutputDirectory, + string? configDirectory, + string fileName + ) + { + var outputDir = configOutputDirectory ?? inputOutputDirectory ?? configDirectory ?? _fileSystem.Directory.GetCurrentDirectory(); + return _fileSystem.Path.Join(outputDir, fileName).OptionalWindowsReplace(); + } + /// The first concrete product id from a profile's output_products/products pattern. private static string? ResolvePrimaryProductFromProfile(BundleProfile profileDef) { @@ -1218,10 +1237,10 @@ Cancel ctx } /// - /// B2 (elastic/docs-builder#3774): bundle output names are standardized by convention as - /// {product}-{version}.yaml. Any profile still setting an explicit output pattern - /// is a hard error, and two profiles sharing the same primary output product would collide on - /// the same conventional target for any given version, so that is rejected as well. + /// B2 (elastic/docs-builder#3774): bundle output names are standardized as + /// {repo}-{product}-{version}.yaml when a repo resolves. Any profile still setting an + /// explicit output pattern is a hard error, and two profiles sharing the same primary + /// output product would collide on the same conventional target, so that is rejected as well. /// private static bool ValidateProfileOutputs(IDiagnosticsCollector collector, ChangelogConfiguration? config) { @@ -1238,7 +1257,7 @@ private static bool ValidateProfileOutputs(IDiagnosticsCollector collector, Chan collector.EmitError( string.Empty, $"Profile '{name}': 'output' is no longer supported. Remove it — bundle output names are now derived by convention " + - "as '{product}-{version}.yaml' from the profile's output_products." + $"as '{BundleOutputNaming.PrefixedConvention}' from the profile's output_products and authoring repo." ); valid = false; } @@ -1254,8 +1273,8 @@ private static bool ValidateProfileOutputs(IDiagnosticsCollector collector, Chan var names = string.Join("', '", group.Select(p => p.Name).Order(StringComparer.Ordinal)); collector.EmitError( string.Empty, - $"Profiles '{names}' all resolve to the same '{group.Key}-{{version}}.yaml' bundle target for any given version. " + - "Bundle names are derived by convention from the profile's primary output product, so each profile must target a distinct product." + $"Profiles '{names}' all resolve to the same '{{repo}}-{group.Key}-{{version}}.yaml' bundle target for any given version. " + + "Bundle names are derived by convention from the authoring repo and the profile's primary output product, so each profile must target a distinct product." ); valid = false; } diff --git a/src/tooling/docs-builder/Commands/ChangelogCommand.cs b/src/tooling/docs-builder/Commands/ChangelogCommand.cs index 9742d84fd..b7b169011 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 {product}-{version}.yaml from the profile's primary output product. + /// 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. /// 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. diff --git a/tests/Elastic.Changelog.Tests/Changelogs/BundleGitRefTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/BundleGitRefTests.cs index e54a38068..bfb64980f 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/BundleGitRefTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/BundleGitRefTests.cs @@ -163,10 +163,10 @@ public async Task ProfileMode_PoolFirstWithInferredFallback_WritesBundleWithGitR ); Collector.Errors.Should().Be(0); - // No profile output pattern → the standardized {product}-{version}.yaml convention applies. + // No profile output pattern → {repo}-{product}-{version}.yaml when bundle.repo is set. var outputFiles = FileSystem.Directory.GetFiles(outputDir, "*.yaml"); outputFiles.Should().ContainSingle(); - FileSystem.Path.GetFileName(outputFiles[0]).Should().Be("cloud-hosted-2026-08-13.yaml"); + FileSystem.Path.GetFileName(outputFiles[0]).Should().Be("widget-cloud-hosted-2026-08-13.yaml"); var bundle = await FileSystem.File.ReadAllTextAsync(outputFiles[0], TestContext.Current.CancellationToken); @@ -336,7 +336,7 @@ public async Task InferredEntry_NoTypeLabel_DefaultsToOtherWithWarning() public async Task Plan_GitRefProfileWithoutOutputPattern_ResolvesConventionalPathAndNetworkNeeds() { // The bundle-create CI action relies on --plan's output_path to locate the generated file, - // so the plan must mirror the {product}-{version}.yaml convention of the real run. + // so the plan must mirror the {repo}-{product}-{version}.yaml convention of the real run. var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(outputDir); var configPath = await WriteProfileConfig(outputDir); @@ -358,7 +358,7 @@ public async Task Plan_GitRefProfileWithoutOutputPattern_ResolvesConventionalPat plan.NeedsNetwork.Should().BeTrue(); plan.NeedsGithubToken.Should().BeTrue(); plan.OutputPath.Should().NotBeNull(); - FileSystem.Path.GetFileName(plan.OutputPath).Should().Be("cloud-hosted-2026-08-13.yaml"); + FileSystem.Path.GetFileName(plan.OutputPath).Should().Be("widget-cloud-hosted-2026-08-13.yaml"); } [Fact] diff --git a/tests/Elastic.Changelog.Tests/Changelogs/BundleOutputConventionTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/BundleOutputConventionTests.cs index f5ac3364e..eaac5d9d8 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/BundleOutputConventionTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/BundleOutputConventionTests.cs @@ -11,9 +11,9 @@ 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 from the profile's primary -/// output product as {product}-{version}.yaml, and two profiles colliding on the same -/// conventional target are rejected. +/// 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. /// public class BundleOutputConventionTests(ITestOutputHelper output) : ChangelogTestBase(output) { @@ -76,7 +76,7 @@ public async Task ProfileWithOutputPattern_EmitsHardError() .Should() .Contain( d => d.Severity == Severity.Error && d.Message.Contains("'output' is no longer supported") && d.Message.Contains( - "{product}-{version}.yaml" + "{repo}-{product}-{version}.yaml" ) ); } @@ -134,7 +134,7 @@ public async Task ProfilesCollidingOnPrimaryProduct_EmitError() .Should() .Contain( d => d.Severity == Severity.Error && d.Message.Contains("'es-all', 'es-ga'") && d.Message.Contains( - "elasticsearch-{version}.yaml" + "{repo}-elasticsearch-{version}.yaml" ) ); } @@ -164,7 +164,195 @@ public async Task ProfileWithoutOutput_WritesConventionalName() .File .Exists(FileSystem.Path.Join(_changelogDir, "elasticsearch-9.3.0.yaml")) .Should() - .BeTrue("bundle names derive from the primary output product and version"); + .BeTrue("when no authoring repo resolves, names fall back to the unprefixed product-version.yaml convention"); + Collector + .Diagnostics + .Should() + .Contain(d => d.Severity == Severity.Warning && d.Message.Contains("Could not resolve a repository name")); + } + + [Fact] + public async Task ProfileWithBundleRepo_PrefixesFileName() + { + var configPath = await WriteConfig( + """ + bundle: + directory: CHANGELOG_DIR + use_local_changelogs: true + repo: kibana + profiles: + serverless-release: + products: "elasticsearch {version} *" + output_products: "cloud-serverless {version}" + """ + ); + + var input = new BundleChangelogsArguments { Profile = "serverless-release", ProfileArgument = "9.3.0", 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, "kibana-cloud-serverless-9.3.0.yaml")) + .Should() + .BeTrue("authoring repo prefixes the conventional product-version name"); + } + + [Fact] + public async Task Plan_ProfileWithBundleRepo_PrefixesFileName() + { + var configPath = await WriteConfig( + """ + bundle: + directory: CHANGELOG_DIR + repo: kibana + profiles: + serverless-release: + products: "elasticsearch {version} *" + output_products: "cloud-serverless {version}" + """ + ); + + var input = new BundleChangelogsArguments { Profile = "serverless-release", ProfileArgument = "9.3.0", Config = configPath }; + var plan = await Service().PlanBundleAsync(Collector, input, hasReleaseVersion: false, TestContext.Current.CancellationToken); + + plan.Should().NotBeNull(); + FileSystem.Path.GetFileName(plan.OutputPath).Should().Be("kibana-cloud-serverless-9.3.0.yaml"); + } + + [Fact] + public async Task CliRepo_OverridesBundleRepo() + { + var configPath = await WriteConfig( + """ + bundle: + directory: CHANGELOG_DIR + use_local_changelogs: true + repo: elasticsearch + profiles: + es-release: + products: "elasticsearch {version} *" + output_products: "elasticsearch {version}" + """ + ); + + var input = new BundleChangelogsArguments + { + Profile = "es-release", + ProfileArgument = "9.3.0", + Config = configPath, + Repo = "kibana" + }; + 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-elasticsearch-9.3.0.yaml")).Should().BeTrue(); + } + + [Fact] + public async Task ProfileRepo_OverridesBundleRepo() + { + var configPath = await WriteConfig( + """ + bundle: + directory: CHANGELOG_DIR + use_local_changelogs: true + repo: elasticsearch + profiles: + es-release: + products: "elasticsearch {version} *" + output_products: "elasticsearch {version}" + repo: kibana + """ + ); + + var input = new BundleChangelogsArguments { Profile = "es-release", ProfileArgument = "9.3.0", 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, "kibana-elasticsearch-9.3.0.yaml")).Should().BeTrue(); + } + + [Fact] + public async Task CombinedOwnerRepo_UsesRepoSegmentOnly() + { + var configPath = await WriteConfig( + """ + bundle: + directory: CHANGELOG_DIR + use_local_changelogs: true + repo: elastic/kibana + profiles: + serverless-release: + products: "elasticsearch {version} *" + output_products: "cloud-serverless {version}" + """ + ); + + var input = new BundleChangelogsArguments { Profile = "serverless-release", ProfileArgument = "9.3.0", 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, "kibana-cloud-serverless-9.3.0.yaml")).Should().BeTrue(); + } + + [Fact] + public async Task GitOrigin_UsedWhenRepoUnset() + { + var gitRoot = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); + FileSystem.Directory.CreateDirectory(FileSystem.Path.Join(gitRoot, ".git")); + await FileSystem.File.WriteAllTextAsync( + FileSystem.Path.Join(gitRoot, ".git", "config"), + """ + [remote "origin"] + url = https://github.com/elastic/kibana.git + """, + TestContext.Current.CancellationToken + ); + + var changelogDir = FileSystem.Path.Join(gitRoot, "changelog"); + FileSystem.Directory.CreateDirectory(changelogDir); + await FileSystem.File.WriteAllTextAsync( + FileSystem.Path.Join(changelogDir, "entry.yaml"), + Entry, + TestContext.Current.CancellationToken + ); + _changelogDir = changelogDir; + + var configPath = FileSystem.Path.Join(gitRoot, "docs", "changelog.yml"); + FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); + await FileSystem.File.WriteAllTextAsync( + configPath, + """ + bundle: + directory: CHANGELOG_DIR + use_local_changelogs: true + profiles: + serverless-release: + products: "elasticsearch {version} *" + output_products: "cloud-serverless {version}" + """.Replace( + "CHANGELOG_DIR", + changelogDir + ), + TestContext.Current.CancellationToken + ); + + var input = new BundleChangelogsArguments { Profile = "serverless-release", ProfileArgument = "9.3.0", 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, "kibana-cloud-serverless-9.3.0.yaml")).Should().BeTrue(); } [Fact] diff --git a/tests/Elastic.Changelog.Tests/Changelogs/BundlePlanTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/BundlePlanTests.cs index cfed8a574..b4dc9d64f 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/BundlePlanTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/BundlePlanTests.cs @@ -75,9 +75,12 @@ public async Task Plan_ProfileMode_RepoResolvable_ReturnsNeedsNetwork() result.Should().NotBeNull(); result.NeedsNetwork.Should().BeTrue(); result.NeedsGithubToken.Should().BeFalse(); - result.OutputPath.Should().EndWith(FileSystem.Path.Join("docs", "releases", "elasticsearch-9.2.0.yaml").OptionalWindowsReplace()); + result + .OutputPath + .Should() + .EndWith(FileSystem.Path.Join("docs", "releases", "elasticsearch-elasticsearch-9.2.0.yaml").OptionalWindowsReplace()); // The bundle-PR action polls this URL for the scrubbed copy: {base}/bundle/{product}/{file}. - result.CdnUrl.Should().Be("https://d10xozp44eyz7q.cloudfront.net/bundle/elasticsearch/elasticsearch-9.2.0.yaml"); + result.CdnUrl.Should().Be("https://d10xozp44eyz7q.cloudfront.net/bundle/elasticsearch/elasticsearch-elasticsearch-9.2.0.yaml"); } [Fact] @@ -104,6 +107,10 @@ public async Task Plan_ProfileMode_NoRepo_ReturnsNoNetwork() result.Should().NotBeNull(); result.NeedsNetwork.Should().BeFalse(); result.CdnUrl.Should().Be("https://d10xozp44eyz7q.cloudfront.net/bundle/elasticsearch/elasticsearch-9.2.0.yaml"); + Collector + .Diagnostics + .Should() + .Contain(d => d.Severity == Severity.Warning && d.Message.Contains("Could not resolve a repository name")); } [Fact] @@ -181,13 +188,16 @@ public async Task Plan_ProfileMode_GitHubRelease_ReturnsNeedsNetwork() // 'source: github_release' names the bundle from ExtractBaseVersion(release.TagName) at run time // (leading 'v' stripped), not the raw CLI argument — plan must mirror that so output_path matches // the file 'bundle' actually writes. - result.OutputPath.Should().EndWith(FileSystem.Path.Join("docs", "releases", "elasticsearch-9.2.0.yaml").OptionalWindowsReplace()); + result + .OutputPath + .Should() + .EndWith(FileSystem.Path.Join("docs", "releases", "elasticsearch-elasticsearch-9.2.0.yaml").OptionalWindowsReplace()); } [Fact] public async Task Plan_ProfileMode_ConventionalName_UsesPrimaryOutputProduct() { - // Output names follow the {product}-{version}.yaml convention; lifecycle only affects + // Output names follow {repo}-{product}-{version}.yaml; lifecycle only affects // product metadata (output_products), never the file name. // language=yaml var configContent = @@ -212,7 +222,7 @@ public async Task Plan_ProfileMode_ConventionalName_UsesPrimaryOutputProduct() result .OutputPath .Should() - .EndWith(FileSystem.Path.Join("docs", "releases", "apm-agent-dotnet-1.0.0.yaml").OptionalWindowsReplace()); + .EndWith(FileSystem.Path.Join("docs", "releases", "apm-agent-dotnet-apm-agent-dotnet-1.0.0.yaml").OptionalWindowsReplace()); } [Fact] diff --git a/tests/Elastic.Changelog.Tests/Changelogs/BundleProfileGitHubReleaseTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/BundleProfileGitHubReleaseTests.cs index 6751d19df..4276bf70c 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/BundleProfileGitHubReleaseTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/BundleProfileGitHubReleaseTests.cs @@ -197,7 +197,10 @@ public async Task ProfileGitHubRelease_AutoInfersVersionAndLifecycle_FromRelease // Output file should be named using the clean version var outputFiles = FileSystem.Directory.GetFiles(outputDir, "*.yaml"); outputFiles.Should().NotBeEmpty(); - outputFiles.Should().Contain(f => f.EndsWith("elasticsearch-9.2.0.yaml"), "Output filename should use clean version"); + outputFiles.Should().Contain( + f => f.EndsWith("elasticsearch-elasticsearch-9.2.0.yaml"), + "Output filename should use repo, product, and clean version" + ); // Bundle products should use inferred lifecycle "ga" var bundleContent = await FileSystem.File.ReadAllTextAsync(outputFiles[0], TestContext.Current.CancellationToken); @@ -599,7 +602,10 @@ public async Task ProfileGitHubRelease_InfersBetaLifecycle_FromTagSuffix() // Output filename should use the clean base version, not the full pre-release tag var outputFiles = FileSystem.Directory.GetFiles(outputDir, "*.yaml"); - outputFiles.Should().Contain(f => f.EndsWith("elasticsearch-9.2.0.yaml"), "Output filename should use clean base version"); + outputFiles.Should().Contain( + f => f.EndsWith("elasticsearch-elasticsearch-9.2.0.yaml"), + "Output filename should use repo, product, and clean base version" + ); var bundleContent = await FileSystem.File.ReadAllTextAsync(outputFiles[0], TestContext.Current.CancellationToken); bundleContent.Should().Contain("target: 9.2.0", "target should be the clean base version"); @@ -675,7 +681,10 @@ public async Task ProfileGitHubRelease_InfersPreviewLifecycle_FromTagSuffix() Collector.Errors.Should().Be(0); var outputFiles = FileSystem.Directory.GetFiles(outputDir, "*.yaml"); - outputFiles.Should().Contain(f => f.EndsWith("apm-agent-dotnet-1.34.1.yaml"), "Output filename should use clean base version"); + outputFiles.Should().Contain( + f => f.EndsWith("apm-agent-dotnet-apm-agent-dotnet-1.34.1.yaml"), + "Output filename should use repo, product, and clean base version" + ); var bundleContent = await FileSystem.File.ReadAllTextAsync(outputFiles[0], TestContext.Current.CancellationToken); bundleContent.Should().Contain("target: 1.34.1", "target should be the clean base version"); diff --git a/tests/Elastic.Changelog.Tests/Changelogs/CloudProfileFixtureTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/CloudProfileFixtureTests.cs index acfe8854e..78ad62a79 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/CloudProfileFixtureTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/CloudProfileFixtureTests.cs @@ -175,7 +175,7 @@ await FileSystem.File.WriteAllTextAsync( var outputFiles = FileSystem.Directory.GetFiles(outputDir, "*.yaml"); outputFiles.Should().ContainSingle("the monthly profile writes a single bundle file"); - FileSystem.Path.GetFileName(outputFiles[0]).Should().Be("cloud-hosted-2026-05.yaml"); + FileSystem.Path.GetFileName(outputFiles[0]).Should().Be("widget-cloud-hosted-2026-05.yaml"); var bundle = await FileSystem.File.ReadAllTextAsync(outputFiles[0], TestContext.Current.CancellationToken);