diff --git a/config/changelog.example.yml b/config/changelog.example.yml index 1a2e480849..f097ac6973 100644 --- a/config/changelog.example.yml +++ b/config/changelog.example.yml @@ -258,17 +258,18 @@ bundle: # docs-builder changelog bundle elasticsearch-release 9.2.0 # docs-builder changelog remove elasticsearch-release 9.2.0 # When used with 'changelog remove', only the 'products' field is applied. - # The 'output', 'output_products', 'repo', 'owner', and 'hide_features' fields are + # 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. 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}" - # # Output filename ({version} is substituted at runtime) - # output: "elasticsearch-{version}.yaml" - # # Optional: override the products array written to the bundle output. - # # output_products: "elasticsearch {version}" + # # The bundle's products metadata; also determines the conventional output + # # file name ({product}-{version}.yaml from the first product). + # output_products: "elasticsearch {version}" # # Optional: profile-specific description (overrides bundle.description) # # description: | # # Elasticsearch {version} includes: @@ -285,12 +286,10 @@ bundle: # source: github_release # Fetch PR list from GitHub release instead of filtering input changelogs # repo: elasticsearch # GitHub repository (required if bundle.repo is not set) # owner: elastic # GitHub owner (optional; defaults to bundle.owner or "elastic") - # output: "elasticsearch-{version}.yaml" # output_products: "elasticsearch {version} {lifecycle}" # Example: Serverless release profile (filter by promotion report, PR, or issue list) # serverless-release: - # output: "serverless-{version}.yaml" # output_products: "cloud-serverless {version}" # omit lifecycle # # output_products: "cloud-serverless {version} {lifecycle}" # derive (ga for ISO dates) # # output_products: "cloud-serverless {version} preview" # hardcode for exceptional releases diff --git a/docs/cli-schema.json b/docs/cli-schema.json index 85bacd2545..46d782ef1d 100644 --- a/docs/cli-schema.json +++ b/docs/cli-schema.json @@ -3140,7 +3140,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. The equivalent configuration option is bundle.profiles.\u003Cname\u003E.output." + "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." }, { "role": "flag", diff --git a/docs/cli/changelog/cmd-bundle.md b/docs/cli/changelog/cmd-bundle.md index 726c63025a..0848611559 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 pattern 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 `{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,10 +33,11 @@ bundle: profiles: elasticsearch-release: products: "elasticsearch {version} {lifecycle}" - output: "elasticsearch/{version}.yaml" output_products: "elasticsearch {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. + ## Option-based mode Supply filter flags directly when you don't have a profile configured or need a one-off bundle. @@ -96,7 +97,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`. When the profile has no explicit `output` pattern, 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 `{product}-{version}.yaml` convention. Re-running the same range produces the same bundle content; bundling never overwrites changelog entries. diff --git a/docs/data/release-notes/bundle.md b/docs/data/release-notes/bundle.md index beae7c8d16..ae0b7bbaf4 100644 --- a/docs/data/release-notes/bundle.md +++ b/docs/data/release-notes/bundle.md @@ -77,17 +77,14 @@ bundle: owner: elastic profiles: serverless-report: - output: "serverless/{version}.yaml" <3> - output_products: "cloud-serverless {version}" <4> + output_products: "cloud-serverless {version}" <3> elasticsearch-release: - output: "elasticsearch/{version}.yaml" output_products: "elasticsearch {version} {lifecycle}" ``` 1. The directory that contains changelog files. 2. The directory that contains changelog bundles. -3. If `output` is omitted, the default path and file names are used. This example shows how you can use a `{version}` variable to customize the bundle's filename. -4. 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. +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`. ### Bundle by GitHub releases [profile-gh-release] @@ -105,12 +102,11 @@ bundle: source: github_release <1> repo: apm-agent-dotnet owner: elastic - output: "agent-{version}.yaml" output_products: "apm-agent-dotnet {version} {lifecycle}" <2> ``` 1. This profile fetches the PR list from the GitHub release notes for the version tag specified in the command. -2. For `source: github_release` profiles, the `{lifecycle}` placeholder in `output` and `output_products` is inferred from full release tag name. For example, if the release tag is `v1.34.1-preview.1` the lifecycle is `preview`. Refer to [](/cli/changelog/bundle.md#lifecycle-inference) for more details. +2. For `source: github_release` profiles, the `{lifecycle}` placeholder in `output_products` is inferred from full release tag name. For example, if the release tag is `v1.34.1-preview.1` the lifecycle is `preview`. Refer to [](/cli/changelog/bundle.md#lifecycle-inference) for more details. ### Bundle by git commit range [profile-git-range] @@ -129,7 +125,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. When the profile has no `output` pattern, 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 `{product}-{version}.yaml` by convention. ```sh docs-builder changelog bundle serverless-release 2026-08-13 \ @@ -159,26 +155,22 @@ bundle: # Collect all changelogs release-all: products: "* * *" <1> - output: "all.yaml" # Find changelogs with any lifecycle and a partial date serverless-monthly: products: "cloud-serverless {version}-* *" <2> - output: "serverless-{version}.yaml" output_products: "cloud-serverless {version}" # Find changelogs with a specific lifecycle - elasticsearch-ga-only: - products: "elasticsearch {version} ga" <3> - output: "elasticsearch-{version}.yaml" + kibana-ga-only: + products: "kibana {version} ga" <3> # Infer the lifecycle from the version elasticsearch-with-lifecycle: products: "elasticsearch {version} {lifecycle}" <4> - output: "elasticsearch-{version}.yaml" output_products: "elasticsearch {version}" ``` 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. -3. This profile collects any changelogs that have `product: elasticsearch`, `lifecycle: ga`, and the version specified in the command. +3. This profile collects any changelogs that have `product: kibana`, `lifecycle: ga`, and the version specified in the command. No two profiles may target the same primary product — they would collide on the same conventional `{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). 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. @@ -432,7 +424,6 @@ bundle: owner: elastic profiles: serverless-report: - output: "serverless/{version}.yaml" output_products: "cloud-serverless {version}" hide_features: <1> - feature-flag-1 diff --git a/docs/data/release-notes/configure-ref.md b/docs/data/release-notes/configure-ref.md index 5e8add87d2..5b8e20fca3 100644 --- a/docs/data/release-notes/configure-ref.md +++ b/docs/data/release-notes/configure-ref.md @@ -124,11 +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` -: The output filename pattern for the bundle file. -: Supports `{version}` and `{lifecycle}` placeholders. -: When not set, 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. -: Setting this is recommended so each profile produces a distinctly named file rather than overwriting the default. -: Example: `"elasticsearch/{version}.yaml"` +: 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. +: 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. `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. diff --git a/src/Elastic.Documentation.Configuration/Changelog/BundleConfiguration.cs b/src/Elastic.Documentation.Configuration/Changelog/BundleConfiguration.cs index f75564bda3..2f127afab6 100644 --- a/src/Elastic.Documentation.Configuration/Changelog/BundleConfiguration.cs +++ b/src/Elastic.Documentation.Configuration/Changelog/BundleConfiguration.cs @@ -86,12 +86,13 @@ public record BundleProfile public string? Products { get; init; } /// - /// Output filename pattern. - /// {version} is substituted at runtime. - /// Examples: - /// - "elasticsearch-{version}.yaml" - /// - "serverless-{version}.yaml" + /// 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. /// + [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.")] public string? Output { get; init; } /// diff --git a/src/Elastic.Documentation.Configuration/Changelog/ChangelogConfigurationLoader.cs b/src/Elastic.Documentation.Configuration/Changelog/ChangelogConfigurationLoader.cs index 733fc51586..15d0354afa 100644 --- a/src/Elastic.Documentation.Configuration/Changelog/ChangelogConfigurationLoader.cs +++ b/src/Elastic.Documentation.Configuration/Changelog/ChangelogConfigurationLoader.cs @@ -522,7 +522,9 @@ private static PivotConfiguration ConvertPivot(PivotConfigurationYaml yamlPivot) : new BundleProfile { Products = kvp.Value.Products, +#pragma warning disable CS0618 // Output stays parseable for one release cycle so ValidateProfileOutputs can emit an actionable error Output = kvp.Value.Output, +#pragma warning restore CS0618 OutputProducts = kvp.Value.OutputProducts, Description = kvp.Value.Description, Repo = kvp.Value.Repo, diff --git a/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs b/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs index c9b9e65bda..4202250e6d 100644 --- a/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs +++ b/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs @@ -201,6 +201,10 @@ public partial class ChangelogBundlingService( [GeneratedRegex(@"github\.com/([^/]+)/([^/]+)/issues/(\d+)", RegexOptions.IgnoreCase)] private static partial Regex GitHubIssueUrlRegex(); + /// A profile argument that is a plain version string (safe for conventional file names), not a report URL/path. + [GeneratedRegex(@"^[A-Za-z0-9._+-]+$")] + private static partial Regex PlanVersionArgumentRegex(); + public async Task BundleChangelogs(IDiagnosticsCollector collector, BundleChangelogsArguments input, Cancel ctx) { try @@ -567,8 +571,11 @@ private async Task BuildAndWriteBundle( private async Task ProcessProfile(IDiagnosticsCollector collector, BundleChangelogsArguments input, ChangelogConfiguration? config, Cancel ctx) { + if (!ValidateProfileOutputs(collector, config)) + return null; + // Commit-range mode derives its PR list from git; the profile only contributes output - // metadata (output/output_products/repo/owner/branch/description), not a filter source. + // metadata (output_products/repo/owner/branch/description), not a filter source. var filterResult = !string.IsNullOrWhiteSpace(input.StartGitRef) ? ResolveGitRangeProfileFilter(collector, input, config) : await ProfileFilterResolver.ResolveAsync( @@ -602,10 +609,12 @@ private async Task BuildAndWriteBundle( // For all other profile types, infer it from the base version string. var resolvedLifecycle = filterResult.Lifecycle ?? VersionLifecycleInference.InferLifecycle(filterResult.Version); - var outputPattern = profile.Output? - .Replace("{version}", filterResult.Version) - .Replace("{lifecycle}", resolvedLifecycle); - if (!string.IsNullOrWhiteSpace(outputPattern)) + // 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. + var primaryProduct = ResolvePrimaryProduct(profile, input); + if (!string.IsNullOrWhiteSpace(primaryProduct) && filterResult.Version != "unknown") { // Resolution order: bundle.output_directory → input.OutputDirectory (programmatic override) // → bundle.directory → CWD @@ -613,22 +622,7 @@ private async Task BuildAndWriteBundle( ?? input.OutputDirectory ?? config.Bundle.Directory ?? _fileSystem.Directory.GetCurrentDirectory(); - outputPath = _fileSystem.Path.Join(outputDir, outputPattern).OptionalWindowsReplace(); - } - else if (!string.IsNullOrWhiteSpace(input.StartGitRef)) - { - // Commit-range bundles follow the standardized {product}-{version}.yaml naming - // convention when the profile sets no explicit output pattern (explicit output: - // patterns are being phased out — see elastic/docs-builder#3774). - var primaryProduct = ResolvePrimaryProduct(profile, input); - if (!string.IsNullOrWhiteSpace(primaryProduct)) - { - var outputDir = config.Bundle.OutputDirectory - ?? input.OutputDirectory - ?? config.Bundle.Directory - ?? _fileSystem.Directory.GetCurrentDirectory(); - outputPath = _fileSystem.Path.Join(outputDir, $"{primaryProduct}-{filterResult.Version}.yaml").OptionalWindowsReplace(); - } + outputPath = _fileSystem.Path.Join(outputDir, $"{primaryProduct}-{filterResult.Version}.yaml").OptionalWindowsReplace(); } // Parse output_products pattern with version/lifecycle substitution @@ -987,10 +981,15 @@ private BundleChangelogsArguments ApplyConfigDefaults(BundleChangelogsArguments config = await _configLoader.LoadChangelogConfiguration(collector, input.Config, ctx); BundleProfile? profileDef = null; - if (!string.IsNullOrWhiteSpace(input.Profile) && - config?.Bundle?.Profiles?.TryGetValue(input.Profile, out profileDef) == true) + if (!string.IsNullOrWhiteSpace(input.Profile)) { - if (string.Equals(profileDef.Source, "github_release", StringComparison.OrdinalIgnoreCase)) + // Plan must fail the same way the run does when profiles still carry output: patterns + // or collide on the conventional target, so CI surfaces the error before the Docker run. + if (!ValidateProfileOutputs(collector, config)) + return null; + + if (config?.Bundle?.Profiles?.TryGetValue(input.Profile, out profileDef) == true && + string.Equals(profileDef.Source, "github_release", StringComparison.OrdinalIgnoreCase)) { needsNetwork = true; needsGithubToken = true; @@ -1007,28 +1006,16 @@ private BundleChangelogsArguments ApplyConfigDefaults(BundleChangelogsArguments if (ShouldSourceFromCdn(authoringRepo, useLocalChangelogs: useLocalChangelogs, explicitDirectory: explicitDirectory)) needsNetwork = true; - // Resolve output path — mirrors the logic in ProcessProfile + ApplyConfigDefaults. + // 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. var outputPath = input.Output; - if (string.IsNullOrWhiteSpace(outputPath) && profileDef?.Output != null) - { - var version = input.ProfileArgument ?? "unknown"; - var lifecycle = VersionLifecycleInference.InferLifecycle(version); - var outputPattern = profileDef.Output - .Replace("{version}", version) - .Replace("{lifecycle}", lifecycle); - var outputDir = config?.Bundle?.OutputDirectory - ?? config?.Bundle?.Directory - ?? _fileSystem.Directory.GetCurrentDirectory(); - outputPath = _fileSystem.Path.Join(outputDir, outputPattern).OptionalWindowsReplace(); - } - else if (string.IsNullOrWhiteSpace(outputPath) && - !string.IsNullOrWhiteSpace(input.StartGitRef) && + if (string.IsNullOrWhiteSpace(outputPath) && profileDef != null && !string.IsNullOrWhiteSpace(input.ProfileArgument) && + PlanVersionArgumentRegex().IsMatch(input.ProfileArgument) && ResolvePrimaryProduct(profileDef, input) is { } primaryProduct) { - // Mirror ProcessProfile's commit-range convention: {product}-{version}.yaml when the - // profile sets no explicit output pattern. var outputDir = config?.Bundle?.OutputDirectory ?? config?.Bundle?.Directory ?? _fileSystem.Directory.GetCurrentDirectory(); @@ -1068,19 +1055,14 @@ private BundleChangelogsArguments ApplyConfigDefaults(BundleChangelogsArguments } /// - /// The first concrete (non-wildcard) product that scopes the bundle, used to build its CDN URL. + /// The first concrete (non-wildcard) product that scopes the bundle, used for the conventional + /// {product}-{version}.yaml 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) { - var pattern = profileDef?.OutputProducts ?? profileDef?.Products; - if (!string.IsNullOrWhiteSpace(pattern)) - { - var firstGroup = pattern.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).FirstOrDefault(); - var id = firstGroup?.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries).FirstOrDefault(); - if (!string.IsNullOrWhiteSpace(id) && id != "*") - return id; - } + if (profileDef != null && ResolvePrimaryProductFromProfile(profileDef) is { } fromProfile) + return fromProfile; foreach (var list in new[] { input.OutputProducts, input.InputProducts }) { @@ -1096,6 +1078,60 @@ private BundleChangelogsArguments ApplyConfigDefaults(BundleChangelogsArguments return null; } + /// The first concrete product id from a profile's output_products/products pattern. + private static string? ResolvePrimaryProductFromProfile(BundleProfile profileDef) + { + var pattern = profileDef.OutputProducts ?? profileDef.Products; + if (string.IsNullOrWhiteSpace(pattern)) + return null; + + var firstGroup = pattern.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).FirstOrDefault(); + var id = firstGroup?.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries).FirstOrDefault(); + return !string.IsNullOrWhiteSpace(id) && id != "*" ? id : null; + } + + /// + /// 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. + /// + private static bool ValidateProfileOutputs(IDiagnosticsCollector collector, ChangelogConfiguration? config) + { + if (config?.Bundle?.Profiles is not { Count: > 0 } profiles) + return true; + + var valid = true; + foreach (var (name, profile) in profiles) + { +#pragma warning disable CS0618 // intentionally reading the obsolete field to reject profiles that still set it + if (string.IsNullOrWhiteSpace(profile.Output)) + continue; +#pragma warning restore CS0618 + 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."); + valid = false; + } + + var collisions = profiles + .Select(kvp => (Name: kvp.Key, Product: ResolvePrimaryProductFromProfile(kvp.Value))) + .Where(p => !string.IsNullOrWhiteSpace(p.Product)) + .GroupBy(p => p.Product, StringComparer.OrdinalIgnoreCase) + .Where(g => g.Count() > 1); + + foreach (var group in collisions) + { + 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."); + valid = false; + } + + return valid; + } + /// Downloads the authoring // pool's changelog entries from the CDN (changelog/{org}/{repo}/{branch}/...); returns null after emitting an error on any fatal fetch failure. private async Task?> FetchCdnEntriesAsync( IDiagnosticsCollector collector, diff --git a/src/tooling/docs-builder/Commands/ChangelogCommand.cs b/src/tooling/docs-builder/Commands/ChangelogCommand.cs index 3cc4e0d6f1..005375b2ef 100644 --- a/src/tooling/docs-builder/Commands/ChangelogCommand.cs +++ b/src/tooling/docs-builder/Commands/ChangelogCommand.cs @@ -568,7 +568,7 @@ async static (s, collector, state, ctx) => await s.CreateChangelog(collector, st /// 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 (*). 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. The equivalent configuration option is bundle.profiles.<name>.output. + /// 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. /// 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/BundleCdnSourcingTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/BundleCdnSourcingTests.cs index 63b770eae0..4daf1a42bd 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/BundleCdnSourcingTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/BundleCdnSourcingTests.cs @@ -290,7 +290,6 @@ public async Task ProfileGitHubRelease_ScopesByOutputProductsAndFiltersByRelease es-release: source: github_release repo: elasticsearch - output: "elasticsearch-{version}.yaml" output_products: "elasticsearch {version} {lifecycle}" """.Replace("PLACEHOLDER", outputDir); var configPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "changelog.yml"); diff --git a/tests/Elastic.Changelog.Tests/Changelogs/BundleChangelogsTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/BundleChangelogsTests.cs index 8f195f8c52..e347713b7b 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/BundleChangelogsTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/BundleChangelogsTests.cs @@ -2305,7 +2305,6 @@ public async Task BundleChangelogs_WithProfileHideFeatures_IncludesHideFeaturesI profiles: es-release: products: "elasticsearch {version} {lifecycle}" - output: "elasticsearch-{version}.yaml" hide_features: - feature:profile-hidden - feature:another-profile-hidden @@ -2376,7 +2375,6 @@ public async Task BundleChangelogs_WithProfile_OnlyProfileHideFeaturesAreUsed() profiles: es-release: products: "elasticsearch {version} {lifecycle}" - output: "elasticsearch-{version}.yaml" hide_features: - feature:from-profile """; @@ -2442,7 +2440,6 @@ public async Task BundleChangelogs_WithProfileMultipleHideFeatures_AllProfileFea profiles: es-release: products: "elasticsearch {version} {lifecycle}" - output: "elasticsearch-{version}.yaml" hide_features: - feature:profile-one - feature:profile-two @@ -2774,7 +2771,6 @@ public async Task BundleChangelogs_WithProfile_OutputProducts_OverridesProductsA profiles: es-release: products: "elasticsearch {version} *" - output: "elasticsearch-{version}.yaml" output_products: "elasticsearch {version} ga" """; @@ -2835,7 +2831,6 @@ public async Task BundleChangelogs_WithProfile_MalformedOutputProducts_EmitsErro profiles: es-release: products: "elasticsearch {version} *" - output: "elasticsearch-{version}.yaml" output_products: "elasticsearch {version} ga extra-token" """; @@ -2888,7 +2883,6 @@ public async Task BundleChangelogs_WithProfile_MalformedProductsPattern_EmitsErr profiles: es-release: products: "elasticsearch {version} ga extra bad" - output: "elasticsearch-{version}.yaml" """; var configPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "changelog.yml"); @@ -2941,7 +2935,6 @@ public async Task BundleChangelogs_WithProfile_DateVersionAndLifecyclePlaceholde bundle: profiles: serverless-release: - output: "serverless-{version}.yaml" output_products: "cloud-serverless {version} {lifecycle}" """; @@ -3011,7 +3004,6 @@ public async Task BundleChangelogs_WithProfile_RepoAndOwner_WritesValuesToProduc profiles: serverless-release: products: "cloud-serverless {version} *" - output: "serverless-{version}.yaml" repo: cloud owner: elastic """; @@ -3077,7 +3069,6 @@ public async Task BundleChangelogs_WithProfile_BundleLevelRepo_AppliesWhenProfil profiles: es-release: products: "elasticsearch {version} {lifecycle}" - output: "elasticsearch-{version}.yaml" """; var configPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "changelog.yml"); @@ -3139,7 +3130,6 @@ public async Task BundleChangelogs_WithProfile_ProfileRepoOverridesBundleRepo() profiles: es-release: products: "elasticsearch {version} {lifecycle}" - output: "elasticsearch-{version}.yaml" repo: elasticsearch """; @@ -3203,7 +3193,6 @@ public async Task BundleChangelogs_WithProfile_NoRepoOwner_PreservesExistingFall profiles: es-release: products: "elasticsearch {version} {lifecycle}" - output: "elasticsearch-{version}.yaml" """; var configPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "changelog.yml"); @@ -3306,7 +3295,6 @@ public async Task BundleChangelogs_WithProfileMode_ConfigAtCurrentDir_LoadsSucce profiles: es-release: products: "elasticsearch {version} {lifecycle}" - output: "elasticsearch-{version}.yaml" """; await cwdFs.File.WriteAllTextAsync(Path.Join(root, "changelog.yml"), configContent, TestContext.Current.CancellationToken); @@ -3366,7 +3354,6 @@ public async Task BundleChangelogs_WithProfileMode_ConfigAtDocsSubdir_LoadsSucce profiles: es-release: products: "elasticsearch {version} {lifecycle}" - output: "elasticsearch-{version}.yaml" """; // Config is in docs/ subdir, not in CWD directly await cwdFs.File.WriteAllTextAsync(Path.Join(root, "docs/changelog.yml"), configContent, TestContext.Current.CancellationToken); @@ -3416,7 +3403,6 @@ public async Task BundleChangelogs_WithProfile_UrlListFile_PrUrls_FiltersCorrect use_local_changelogs: true profiles: release: - output: "bundle.yaml" """; var configPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "changelog.yml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); @@ -3460,8 +3446,9 @@ await FileSystem.File.WriteAllTextAsync( TestContext.Current.CancellationToken ); - // Profile writes to _changelogDir/bundle.yaml because bundle.directory is the fallback for output_directory - var expectedOutputPath = FileSystem.Path.Join(_changelogDir, "bundle.yaml"); + // URL-list invocations have no version, so the default changelog-bundle.yaml naming applies + // in bundle.directory (the fallback for output_directory). + var expectedOutputPath = FileSystem.Path.Join(_changelogDir, "changelog-bundle.yaml"); var input = new BundleChangelogsArguments { @@ -3492,7 +3479,6 @@ public async Task BundleChangelogs_WithProfile_UrlListFile_IssueUrls_FiltersCorr use_local_changelogs: true profiles: release: - output: "bundle.yaml" """; var configPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "changelog.yml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); @@ -3536,9 +3522,9 @@ await FileSystem.File.WriteAllTextAsync( TestContext.Current.CancellationToken ); - // Profile writes to _changelogDir/bundle.yaml (output: "bundle.yaml" + no output_directory in config) - // Profile writes to _changelogDir/bundle.yaml because bundle.directory is the fallback for output_directory - var expectedOutputPath = FileSystem.Path.Join(_changelogDir, "bundle.yaml"); + // URL-list invocations have no version, so the default changelog-bundle.yaml naming applies + // in bundle.directory (the fallback for output_directory). + var expectedOutputPath = FileSystem.Path.Join(_changelogDir, "changelog-bundle.yaml"); var input = new BundleChangelogsArguments { @@ -3568,7 +3554,6 @@ public async Task BundleChangelogs_WithProfile_UrlListFile_Numbers_ReturnsError( bundle: profiles: release: - output: "bundle.yaml" """; var configPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "changelog.yml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); @@ -3621,7 +3606,6 @@ public async Task BundleChangelogs_WithProfile_UrlListFile_MixedPrsAndIssues_Ret bundle: profiles: release: - output: "bundle.yaml" """; var configPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "changelog.yml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); @@ -3679,7 +3663,6 @@ public async Task BundleChangelogs_WithProfile_CombinedVersionAndReport_Substitu profiles: serverless-release: output_products: "cloud-serverless {version}" - output: "serverless-{version}.yaml" """; var configPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "changelog.yml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); @@ -3767,7 +3750,6 @@ public async Task BundleChangelogs_WithProfile_CombinedVersion_ReportArgLooksLik bundle: profiles: serverless-release: - output: "serverless-{version}.yaml" """; var configPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "changelog.yml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); @@ -3814,7 +3796,6 @@ public async Task BundleChangelogs_WithProfile_CombinedVersion_ProfileHasProduct profiles: release: products: "elasticsearch 9.2.0 ga" - output: "bundle.yaml" """; var configPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "changelog.yml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); diff --git a/tests/Elastic.Changelog.Tests/Changelogs/BundleFilesFilterTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/BundleFilesFilterTests.cs index 7ba1ab2e2e..d9a9931708 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/BundleFilesFilterTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/BundleFilesFilterTests.cs @@ -160,7 +160,6 @@ public async Task Bundle_WithProfile_PathListFile_FiltersCorrectly() directory: {_changelogDir} profiles: release: - output: "bundle.yaml" """; var configPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "changelog.yml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); @@ -175,7 +174,7 @@ public async Task Bundle_WithProfile_PathListFile_FiltersCorrectly() FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(listFile)!); await FileSystem.File.WriteAllTextAsync(listFile, "keep.yaml\n", TestContext.Current.CancellationToken); - var expectedOutput = FileSystem.Path.Join(_changelogDir, "bundle.yaml"); + var expectedOutput = FileSystem.Path.Join(_changelogDir, "changelog-bundle.yaml"); var input = new BundleChangelogsArguments { Config = configPath, @@ -200,7 +199,6 @@ public async Task Bundle_WithProfile_MixedUrlsAndPaths_ReturnsError() bundle: profiles: release: - output: "bundle.yaml" """; var configPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "changelog.yml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); @@ -360,7 +358,6 @@ public async Task Bundle_WithProfile_PathListFile_RepoResolves_SourcesFromCdn() repo: elasticsearch profiles: release: - output: "bundle.yaml" """; var configPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "changelog.yml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); @@ -386,7 +383,7 @@ public async Task Bundle_WithProfile_PathListFile_RepoResolves_SourcesFromCdn() result.Should().BeTrue($"Errors: {string.Join("; ", Collector.Diagnostics.Select(d => d.Message))}"); handler.RequestedPaths.Should().Contain("/changelog/elastic/elasticsearch/main/registry.json"); var bundle = await FileSystem.File.ReadAllTextAsync( - FileSystem.Path.Join(outputDir, "bundle.yaml"), TestContext.Current.CancellationToken); + FileSystem.Path.Join(outputDir, "changelog-bundle.yaml"), TestContext.Current.CancellationToken); bundle.Should().Contain("name: keep.yaml"); bundle.Should().NotContain("name: skip.yaml"); } diff --git a/tests/Elastic.Changelog.Tests/Changelogs/BundleOutputConventionTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/BundleOutputConventionTests.cs new file mode 100644 index 0000000000..bef81ee53e --- /dev/null +++ b/tests/Elastic.Changelog.Tests/Changelogs/BundleOutputConventionTests.cs @@ -0,0 +1,160 @@ +// 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 AwesomeAssertions; +using Elastic.Changelog.Bundling; +using Elastic.Documentation.Configuration; +using Elastic.Documentation.Diagnostics; + +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. +/// +public class BundleOutputConventionTests(ITestOutputHelper output) : ChangelogTestBase(output) +{ + // language=yaml + private const string Entry = """ + title: Sample change + type: feature + products: + - product: elasticsearch + target: 9.3.0 + lifecycle: ga + """; + + private string _changelogDir = string.Empty; + + private async Task WriteConfig(string configContent) + { + _changelogDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); + FileSystem.Directory.CreateDirectory(_changelogDir); + await FileSystem.File.WriteAllTextAsync(FileSystem.Path.Join(_changelogDir, "entry.yaml"), Entry, TestContext.Current.CancellationToken); + + var configPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "changelog.yml"); + FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); + await FileSystem.File.WriteAllTextAsync(configPath, configContent.Replace("CHANGELOG_DIR", _changelogDir), TestContext.Current.CancellationToken); + return configPath; + } + + private ChangelogBundlingService Service() => new(LoggerFactory, FileSystem, ConfigurationContext); + + [Fact] + public async Task ProfileWithOutputPattern_EmitsHardError() + { + var configPath = await WriteConfig(""" + bundle: + directory: CHANGELOG_DIR + use_local_changelogs: true + profiles: + es-release: + products: "elasticsearch {version} *" + output: "elasticsearch-{version}.yaml" + """); + + 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().BeFalse(); + Collector.Diagnostics.Should().Contain(d => + d.Severity == Severity.Error && + d.Message.Contains("'output' is no longer supported") && + d.Message.Contains("{product}-{version}.yaml")); + } + + [Fact] + public async Task OutputPatternOnAnotherProfile_AlsoErrors() + { + // The validation covers every profile in the file, not just the invoked one — a stale + // output: elsewhere would silently produce an unexpected path on its next invocation. + var configPath = await WriteConfig(""" + bundle: + directory: CHANGELOG_DIR + use_local_changelogs: true + profiles: + es-release: + products: "elasticsearch {version} *" + legacy: + products: "cloud-hosted {version} *" + output: "legacy-{version}.yaml" + """); + + 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().BeFalse(); + Collector.Diagnostics.Should().Contain(d => + d.Severity == Severity.Error && d.Message.Contains("Profile 'legacy'")); + } + + [Fact] + public async Task ProfilesCollidingOnPrimaryProduct_EmitError() + { + var configPath = await WriteConfig(""" + bundle: + directory: CHANGELOG_DIR + use_local_changelogs: true + profiles: + es-ga: + products: "elasticsearch {version} ga" + output_products: "elasticsearch {version}" + es-all: + products: "elasticsearch {version} *" + output_products: "elasticsearch {version}" + """); + + var input = new BundleChangelogsArguments { Profile = "es-ga", ProfileArgument = "9.3.0", Config = configPath }; + var result = await Service().BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); + + result.Should().BeFalse(); + Collector.Diagnostics.Should().Contain(d => + d.Severity == Severity.Error && + d.Message.Contains("'es-all', 'es-ga'") && + d.Message.Contains("elasticsearch-{version}.yaml")); + } + + [Fact] + public async Task ProfileWithoutOutput_WritesConventionalName() + { + var configPath = await WriteConfig(""" + bundle: + directory: CHANGELOG_DIR + use_local_changelogs: true + profiles: + es-release: + products: "elasticsearch {version} *" + output_products: "elasticsearch {version}" + """); + + 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, "elasticsearch-9.3.0.yaml")) + .Should().BeTrue("bundle names derive from the primary output product and version"); + } + + [Fact] + public async Task Plan_ProfileWithOutputPattern_FailsTheSameWay() + { + var configPath = await WriteConfig(""" + bundle: + directory: CHANGELOG_DIR + profiles: + es-release: + products: "elasticsearch {version} *" + output: "elasticsearch-{version}.yaml" + """); + + var input = new BundleChangelogsArguments { Profile = "es-release", ProfileArgument = "9.3.0", Config = configPath }; + var plan = await Service().PlanBundleAsync(Collector, input, hasReleaseVersion: false, TestContext.Current.CancellationToken); + + plan.Should().BeNull(); + Collector.Diagnostics.Should().Contain(d => + d.Severity == Severity.Error && d.Message.Contains("'output' is no longer supported")); + } +} diff --git a/tests/Elastic.Changelog.Tests/Changelogs/BundlePlanTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/BundlePlanTests.cs index bab8772ab3..07934d96c8 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/BundlePlanTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/BundlePlanTests.cs @@ -66,7 +66,6 @@ public async Task Plan_ProfileMode_RepoResolvable_ReturnsNeedsNetwork() profiles: my-profile: products: "elasticsearch {version} {lifecycle}" - output: "elasticsearch-{version}.yaml" """; var configPath = await CreateConfigAsync(configContent); @@ -101,7 +100,6 @@ public async Task Plan_ProfileMode_NoRepo_ReturnsNoNetwork() profiles: my-profile: products: "elasticsearch {version} {lifecycle}" - output: "elasticsearch-{version}.yaml" """; var configPath = await CreateConfigAsync(configContent); @@ -132,7 +130,6 @@ public async Task Plan_ProfileMode_OutputProductsScopeCdnUrl() serverless: products: "cloud-serverless {version} *" output_products: "cloud-serverless {version} *" - output: "serverless-{version}.yaml" """; var configPath = await CreateConfigAsync(configContent); @@ -146,7 +143,7 @@ public async Task Plan_ProfileMode_OutputProductsScopeCdnUrl() var result = await Service.PlanBundleAsync(Collector, input, hasReleaseVersion: false, TestContext.Current.CancellationToken); result.Should().NotBeNull(); - result.CdnUrl.Should().Be("https://d10xozp44eyz7q.cloudfront.net/bundle/cloud-serverless/serverless-2026-03.yaml"); + result.CdnUrl.Should().Be("https://d10xozp44eyz7q.cloudfront.net/bundle/cloud-serverless/cloud-serverless-2026-03.yaml"); } [Fact] @@ -162,7 +159,6 @@ public async Task Plan_ProfileMode_UseLocalChangelogs_ReturnsNoNetwork() profiles: my-profile: products: "elasticsearch {version} {lifecycle}" - output: "elasticsearch-{version}.yaml" """; var configPath = await CreateConfigAsync(configContent); @@ -192,7 +188,7 @@ public async Task Plan_ProfileMode_GitHubRelease_ReturnsNeedsNetwork() es-release: source: github_release repo: elasticsearch - output: "elasticsearch-{version}.yaml" + output_products: "elasticsearch {version}" """; var configPath = await CreateConfigAsync(configContent); @@ -212,8 +208,10 @@ public async Task Plan_ProfileMode_GitHubRelease_ReturnsNeedsNetwork() } [Fact] - public async Task Plan_ProfileMode_LifecycleSubstitution_ResolvesCorrectly() + public async Task Plan_ProfileMode_ConventionalName_UsesPrimaryOutputProduct() { + // Output names follow the {product}-{version}.yaml convention; lifecycle only affects + // product metadata (output_products), never the file name. // language=yaml var configContent = """ @@ -223,7 +221,7 @@ public async Task Plan_ProfileMode_LifecycleSubstitution_ResolvesCorrectly() dotnet-release: source: github_release repo: apm-agent-dotnet - output: "dotnet-{version}-{lifecycle}.yaml" + output_products: "apm-agent-dotnet {version} {lifecycle}" """; var configPath = await CreateConfigAsync(configContent); @@ -237,7 +235,7 @@ public async Task Plan_ProfileMode_LifecycleSubstitution_ResolvesCorrectly() var result = await Service.PlanBundleAsync(Collector, input, hasReleaseVersion: false, TestContext.Current.CancellationToken); result.Should().NotBeNull(); - result.OutputPath.Should().EndWith(FileSystem.Path.Join("docs", "releases", "dotnet-1.0.0-beta.1-beta.yaml").OptionalWindowsReplace()); + result.OutputPath.Should().EndWith(FileSystem.Path.Join("docs", "releases", "apm-agent-dotnet-1.0.0-beta.1.yaml").OptionalWindowsReplace()); } [Fact] diff --git a/tests/Elastic.Changelog.Tests/Changelogs/BundleProfileGitHubReleaseTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/BundleProfileGitHubReleaseTests.cs index 72347cffa2..68e8d62d3c 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/BundleProfileGitHubReleaseTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/BundleProfileGitHubReleaseTests.cs @@ -54,7 +54,6 @@ public async Task ProfileGitHubRelease_BundlesMatchingChangelogs() es-gh-release: source: github_release repo: elasticsearch - output: "elasticsearch-{version}.yaml" output_products: "elasticsearch {version} {lifecycle}" """.Replace("PLACEHOLDER", _changelogDir); @@ -144,7 +143,6 @@ public async Task ProfileGitHubRelease_AutoInfersVersionAndLifecycle_FromRelease es-gh-release: source: github_release repo: elasticsearch - output: "elasticsearch-{version}.yaml" output_products: "elasticsearch {version} {lifecycle}" """.Replace("PLACEHOLDER", _changelogDir); @@ -217,7 +215,6 @@ public async Task ProfileGitHubRelease_WithNoMatchingPrs_EmitsWarning() es-gh-release: source: github_release repo: elasticsearch - output: "elasticsearch-{version}.yaml" """.Replace("PLACEHOLDER", _changelogDir); var configPath = await CreateConfigAsync(configContent); @@ -259,7 +256,6 @@ public async Task ProfileGitHubRelease_FetchFailure_ReturnsError() es-gh-release: source: github_release repo: elasticsearch - output: "elasticsearch-{version}.yaml" """; var configPath = await CreateConfigAsync(configContent); @@ -298,7 +294,6 @@ public async Task ProfileGitHubRelease_Latest_CallsFetchWithLatestTag() es-gh-release: source: github_release repo: elasticsearch - output: "elasticsearch-{version}.yaml" output_products: "elasticsearch {version} {lifecycle}" """.Replace("PLACEHOLDER", _changelogDir); @@ -359,7 +354,6 @@ public async Task ProfileGitHubRelease_RequiresRepo_ReturnsError() profiles: es-gh-release: source: github_release - output: "elasticsearch-{version}.yaml" """; var configPath = await CreateConfigAsync(configContent); @@ -398,7 +392,6 @@ public async Task ProfileGitHubRelease_MutuallyExclusiveWithProducts_ReturnsErro source: github_release repo: elasticsearch products: "elasticsearch {version} {lifecycle}" - output: "elasticsearch-{version}.yaml" """; var configPath = await CreateConfigAsync(configContent); @@ -436,7 +429,6 @@ public async Task ProfileGitHubRelease_MutuallyExclusiveWithPromotionReport_Retu es-gh-release: source: github_release repo: elasticsearch - output: "elasticsearch-{version}.yaml" """; var configPath = await CreateConfigAsync(configContent); @@ -478,7 +470,6 @@ public async Task ProfileGitHubRelease_InfersBetaLifecycle_FromTagSuffix() es-gh-release: source: github_release repo: elasticsearch - output: "elasticsearch-{version}.yaml" output_products: "elasticsearch {version} {lifecycle}" """.Replace("PLACEHOLDER", _changelogDir); @@ -548,7 +539,6 @@ public async Task ProfileGitHubRelease_InfersPreviewLifecycle_FromTagSuffix() es-gh-release: source: github_release repo: apm-agent-dotnet - output: "apm-agent-dotnet-{version}.yaml" output_products: "apm-agent-dotnet {version} {lifecycle}" """.Replace("PLACEHOLDER", _changelogDir); @@ -617,7 +607,6 @@ public async Task ProfileGitHubRelease_BundleLevelRepo_UsedWhenProfileOmitsRepo( profiles: es-gh-release: source: github_release - output: "elasticsearch-{version}.yaml" output_products: "elasticsearch {version} {lifecycle}" """.Replace("PLACEHOLDER", _changelogDir); diff --git a/tests/Elastic.Changelog.Tests/Changelogs/CloudProfileFixtureTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/CloudProfileFixtureTests.cs index 32294e20b7..5eff19f94f 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/CloudProfileFixtureTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/CloudProfileFixtureTests.cs @@ -124,7 +124,6 @@ public async Task MonthlyProfile_SourcesFromRepoPool_ProducesSoundBundle() profiles: wh-monthly: products: "cloud-hosted {version}-* *" - output: "widget-{version}.yaml" output_products: "cloud-hosted {version}" """.Replace("PLACEHOLDER", outputDir); @@ -154,7 +153,7 @@ public async Task MonthlyProfile_SourcesFromRepoPool_ProducesSoundBundle() 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("widget-2026-05.yaml"); + FileSystem.Path.GetFileName(outputFiles[0]).Should().Be("cloud-hosted-2026-05.yaml"); var bundle = await FileSystem.File.ReadAllTextAsync(outputFiles[0], TestContext.Current.CancellationToken);