From a05100ae47f68ea3747237296e27f0dedcb72d13 Mon Sep 17 00:00:00 2001 From: lcawl Date: Wed, 25 Feb 2026 15:55:55 -0800 Subject: [PATCH 1/9] Enforce mutual exclusivity for profile-based changelog commands and add output_products, repo, owner profile fields Phase 1: - Bundle command: reject all filter/output options (--all, --input-products, --output-products, --prs, --issues, --output, --repo, --owner, --resolve, --no-resolve, --hide-features, --config, --directory) when a profile argument is given. - Remove command: reject all options except --dry-run and --force when a profile argument is given. - Profile-based commands now discover changelog.yml automatically (./changelog.yml then ./docs/changelog.yml) and return a helpful error if neither is found, rather than silently falling back to defaults. An explicit config path is still accepted when passed directly to the service layer (used by tests). - ChangelogRemoveArguments.Directory changed from required to nullable; ApplyConfigDefaults follows the same null-coalescing pattern as the bundle service. - Fixes a silent bug where --output was ignored in profile mode. Phase 2: - Add output_products, repo, and owner fields to BundleProfileYaml, BundleProfile, and the config loader mapping. - ProcessProfile applies these fields when building the bundled output: output_products overrides the products array with version/lifecycle substitution; repo and owner are stored on each product entry for correct PR/issue link generation. - MergeHideFeatures removed; profile mode now uses only the profile's hide_features (CLI --hide-features is rejected at the command layer). - Update changelog.example.yml and docs to document all new profile fields and the mutual exclusivity requirement. Made-with: Cursor --- config/changelog.example.yml | 15 ++ docs/cli/release/changelog-bundle.md | 60 +++++- docs/cli/release/changelog-remove.md | 28 ++- .../Changelog/BundleConfiguration.cs | 18 ++ .../Bundling/ChangelogBundlingService.cs | 59 ++++-- .../Bundling/ChangelogRemoveService.cs | 42 ++-- .../ChangelogConfigurationLoader.cs | 95 +++++++++ .../ChangelogConfigurationYaml.cs | 17 ++ .../docs-builder/Commands/ChangelogCommand.cs | 188 +++++++++++------- .../Changelogs/BundleChangelogsTests.cs | 33 ++- 10 files changed, 414 insertions(+), 141 deletions(-) diff --git a/config/changelog.example.yml b/config/changelog.example.yml index e677eef4b..519c25a40 100644 --- a/config/changelog.example.yml +++ b/config/changelog.example.yml @@ -179,13 +179,28 @@ bundle: profiles: # Example: Elasticsearch release profile # 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. + # # Useful when the bundle should advertise a different lifecycle than was used for filtering. + # # output_products: "elasticsearch {version} ga" + # # Optional: GitHub repo name written to each product entry in the bundle. + # # Used by the {changelog} directive to generate correct PR/issue links. + # # Only needed when the product ID does not match the GitHub repository name. + # # repo: elasticsearch + # # Optional: GitHub owner written to each product entry in the bundle (default: elastic). + # # owner: elastic # Example: Serverless release profile # serverless-release: # products: "cloud-serverless {version} *" # output: "serverless-{version}.yaml" + # # repo and owner are required here because the product ID "cloud-serverless" differs + # # from the GitHub repository name "cloud". + # # repo: cloud + # # owner: elastic # # Feature IDs to hide when bundling with this profile (accepts string or list) # hide_features: # - feature-flag-1 diff --git a/docs/cli/release/changelog-bundle.md b/docs/cli/release/changelog-bundle.md index c8ded7003..33e87bdad 100644 --- a/docs/cli/release/changelog-bundle.md +++ b/docs/cli/release/changelog-bundle.md @@ -11,9 +11,19 @@ For details and examples, go to [](/contribute/changelog.md). docs-builder changelog bundle [arguments...] [options...] [-h|--help] ``` +## Profile-based vs. option-based usage [changelog-bundle-invocation-modes] + +`changelog bundle` supports two mutually exclusive invocation modes: + +- **Profile-based**: `bundle ` — all paths and filters come from the changelog configuration file. No other options are allowed. +- **Option-based**: `bundle --all` (or `--input-products`, `--prs`, `--issues`) — you supply all filter and output options directly. + +You cannot mix the two modes. Passing any option-based flag together with a profile returns an error. + +Profile-based commands discover the changelog configuration automatically (no `--config` flag): they look for `changelog.yml` in the current directory, then `docs/changelog.yml`. If neither file is found, the command returns an error with instructions to run `docs-builder changelog init` or to re-run from the folder where the file exists. + ## Arguments -You can use either profile-based bundling (for example, `bundle elasticsearch-release 9.2.0`) or raw flags (`bundle --all`). These arguments apply to profile-based bundling: `[0] ` @@ -25,7 +35,9 @@ These arguments apply to profile-based bundling: : Version number or promotion report URL or path. : For example, "9.2.0" or "https://buildkite.../promotion-report.html". -## Options +## Options (option-based mode only) + +The following options are only valid in option-based mode (no profile argument). Using any of them with a profile returns an error. `--all` : Include all changelogs from the directory. @@ -115,7 +127,7 @@ If you specify a file path with a different extension (not `.yml` or `.yaml`), t ## Repository name in bundles [changelog-bundle-repo] -When you specify the `--repo` option, the repository name is stored in the bundle's product metadata. +When you specify the `--repo` option (option-based mode) or the `repo` field in a profile (profile-based mode), the repository name is stored in the bundle's product metadata. This ensures that PR and issue links are generated correctly when the bundle is rendered. ```sh @@ -146,3 +158,45 @@ When rendering, pull request and issue links will use `https://github.com/elasti If the `repo` field is not specified, the product ID is used as a fallback for link generation. This may result in broken links if the product ID doesn't match the GitHub repository name (for example, `cloud-serverless` vs `cloud`). ::: + +## Profile configuration fields [changelog-bundle-profile-config] + +Bundle profiles in `changelog.yml` support the following fields: + +`products` +: Required. The product filter pattern for input changelogs. Supports `{version}` and `{lifecycle}` placeholders that are substituted at runtime. +: Example: `"elasticsearch {version} {lifecycle}"` + +`output` +: Required for bundling. The output filename pattern. `{version}` is substituted at runtime. +: Example: `"elasticsearch-{version}.yaml"` + +`output_products` +: Optional. Overrides the products array written to the bundle output. Supports `{version}` and `{lifecycle}` placeholders. Useful when the bundle should advertise a different lifecycle than was used for filtering — for example, when filtering by `preview` changelogs to produce a `ga` bundle. +: Example: `"elasticsearch {version} ga"` + +`repo` +: Optional. The GitHub repository name written to each product entry in the bundle. Used by the `{changelog}` directive to generate correct PR/issue links. Only needed when the product ID doesn't match the GitHub repository name. +: Example: `repo: cloud` (for the `cloud-serverless` product) + +`owner` +: Optional. The GitHub owner written to each product entry in the bundle. Defaults to `elastic` when not specified. +: Example: `owner: elastic` + +`hide_features` +: Optional. Feature IDs to mark as hidden in the bundle output (string or list). When the bundle is rendered, entries with matching `feature-id` values are commented out. + +Example profile with all fields: + +```yaml +bundle: + profiles: + elasticsearch-release: + products: "elasticsearch {version} {lifecycle}" + output: "elasticsearch-{version}.yaml" + output_products: "elasticsearch {version} ga" + repo: elasticsearch + owner: elastic + hide_features: + - feature:my-feature-flag +``` diff --git a/docs/cli/release/changelog-remove.md b/docs/cli/release/changelog-remove.md index eae8448dd..95437a703 100644 --- a/docs/cli/release/changelog-remove.md +++ b/docs/cli/release/changelog-remove.md @@ -5,7 +5,9 @@ Remove changelog YAML files from a directory. You can use either profile-based removal or raw filter flags: - **Profile-based**: `docs-builder changelog remove ` — uses the same `bundle.profiles` configuration as [`changelog bundle`](/cli/release/changelog-bundle.md) to determine which changelogs to remove. -- **Raw flags**: `docs-builder changelog remove --products "..." ` (or `--prs`, `--issues`, `--all`) — specify the filter directly. +- **Option-based**: `docs-builder changelog remove --products "..." ` (or `--prs`, `--issues`, `--all`) — specify the filter directly. + +These modes are mutually exclusive. You can't combine a profile argument with option-based flags. Before deleting anything, the command checks whether any of the matching files are referenced by unresolved bundles, to prevent silently breaking the `{changelog}` directive. @@ -19,14 +21,13 @@ docs-builder changelog remove [arguments...] [options...] [-h|--help] ## Arguments -You can use either profile-based removal (for example, `remove elasticsearch-release 9.2.0`) or raw flags (`remove --all`). These arguments apply to profile-based removal: `[0] ` : Profile name from `bundle.profiles` in the changelog configuration file. : For example, "elasticsearch-release". : When specified, the second argument is the version or promotion report URL. -: Mutually exclusive with `--all`, `--products`, `--prs`, and `--issues`. +: Mutually exclusive with `--all`, `--products`, `--prs`, `--issues`, `--owner`, `--repo`, `--config`, `--directory`, and `--bundles-dir`. `[1] ` : Version number or promotion report URL or path. @@ -37,42 +38,46 @@ These arguments apply to profile-based removal: `--all` : Remove all changelog files in the directory. : Exactly one filter option must be specified: `--all`, `--products`, `--prs`, or `--issues`. -: Cannot be combined with a profile argument. +: Not allowed with a profile argument. `--bundles-dir ` : Optional: Override the directory scanned for bundles during the dependency check. : When not specified, the directory is discovered automatically from config or fallback paths. +: Not allowed with a profile argument. In profile mode, the bundles directory is derived from `bundle.output_directory` in the changelog configuration. `--config ` : Optional: Path to the changelog configuration file. : Defaults to `docs/changelog.yml`. +: Not allowed with a profile argument. In profile mode, the configuration is discovered automatically. `--directory ` : Optional: The directory that contains the changelog YAML files. : When not specified, uses `bundle.directory` from the changelog configuration if set, otherwise the current directory. +: Not allowed with a profile argument. In profile mode, the directory is derived from `bundle.directory` in the changelog configuration. `--dry-run` : Print the files that would be removed and any bundle dependency conflicts, without deleting anything. -: Valid in both profile and raw mode. +: Valid in both profile and option-based mode. `--force` : Proceed with removal even when files are referenced by unresolved bundles. : Emits a warning per dependency instead of blocking. -: Valid in both profile and raw mode. +: Valid in both profile and option-based mode. `--issues ` : Filter by issue URLs or numbers (comma-separated), or a path to a newline-delimited file containing issue URLs or numbers. : Can be specified multiple times. : Exactly one filter option must be specified: `--all`, `--products`, `--prs`, or `--issues`. -: Cannot be combined with a profile argument. +: Not allowed with a profile argument. `--owner ` : The GitHub repository owner, which is required when pull requests or issues are specified as numbers. +: Not allowed with a profile argument. `--products ?>` : Filter by products in format `"product target lifecycle, ..."` : Exactly one filter option must be specified: `--all`, `--products`, `--prs`, or `--issues`. -: Cannot be combined with a profile argument. +: Not allowed with a profile argument. : All three parts (product, target, lifecycle) are required but can be wildcards (`*`). Multiple comma-separated values are combined with OR: a changelog is removed if it matches any of the specified product/target/lifecycle combinations. For example: - `"elasticsearch 9.3.0 ga"` — exact match @@ -85,16 +90,19 @@ These arguments apply to profile-based removal: : Filter by pull request URLs or numbers (comma-separated), or a path to a newline-delimited file containing PR URLs or numbers. : Can be specified multiple times. : Exactly one filter option must be specified: `--all`, `--products`, `--prs`, or `--issues`. -: Cannot be combined with a profile argument. +: Not allowed with a profile argument. `--repo ` : The GitHub repository name, which is required when pull requests or issues are specified as numbers. +: Not allowed with a profile argument. ## Profile-based removal [changelog-remove-profile] When a `changelog.yml` configuration file defines `bundle.profiles`, you can use those same profiles with `changelog remove` to remove exactly the changelogs that would be included in a matching bundle. -Only the `products` field from a profile is used for removal. The `output` and `hide_features` fields are bundle-specific and are ignored. +Profile-based commands discover the changelog configuration automatically (no `--config` flag): they look for `changelog.yml` in the current directory, then `docs/changelog.yml`. If neither file is found, the command returns an error with instructions to run `docs-builder changelog init` or to re-run from the folder where the file exists. + +Only the `products` field from a profile is used for removal. The `output`, `output_products`, `repo`, `owner`, and `hide_features` fields are bundle-specific and are ignored. For example, if your configuration file defines: diff --git a/src/Elastic.Documentation.Configuration/Changelog/BundleConfiguration.cs b/src/Elastic.Documentation.Configuration/Changelog/BundleConfiguration.cs index f51f2722c..544f83f1a 100644 --- a/src/Elastic.Documentation.Configuration/Changelog/BundleConfiguration.cs +++ b/src/Elastic.Documentation.Configuration/Changelog/BundleConfiguration.cs @@ -57,6 +57,24 @@ public record BundleProfile /// public string? Output { get; init; } + /// + /// Output products pattern. When set, overrides the products array derived from matched changelogs. + /// Supports {version} and {lifecycle} placeholders. + /// + public string? OutputProducts { get; init; } + + /// + /// GitHub repository name stored on each product in the bundle output. + /// Used for generating correct PR/issue links when the product ID differs from the repo name. + /// + public string? Repo { get; init; } + + /// + /// GitHub repository owner stored on each product in the bundle output. + /// Used for generating correct PR/issue links. Defaults to "elastic" when not specified. + /// + public string? Owner { get; init; } + /// /// Feature IDs to mark as hidden in the bundle output. /// When the bundle is rendered, entries with matching feature-id values will be commented out. diff --git a/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs b/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs index a5cbb0377..8ec123e71 100644 --- a/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs +++ b/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs @@ -97,9 +97,25 @@ public async Task BundleChangelogs(IDiagnosticsCollector collector, Bundle { try { - // Load changelog configuration if available + // Load changelog configuration ChangelogConfiguration? config = null; - if (_configLoader != null) + if (!string.IsNullOrWhiteSpace(input.Profile)) + { + // Profile mode requires the config file to exist — no fallback to defaults. + if (_configLoader == null) + { + collector.EmitError(string.Empty, "Changelog configuration loader is required for profile-based bundling."); + return false; + } + // When an explicit config path is provided, load it (required, no fallback). + // Otherwise, discover from CWD: ./changelog.yml then ./docs/changelog.yml. + config = string.IsNullOrWhiteSpace(input.Config) + ? await _configLoader.LoadChangelogConfigurationForProfileMode(collector, ctx) + : await _configLoader.LoadChangelogConfigurationRequired(collector, input.Config, ctx); + if (config == null) + return false; + } + else if (_configLoader != null) config = await _configLoader.LoadChangelogConfiguration(collector, input.Config, ctx); // Handle profile-based bundling @@ -224,8 +240,11 @@ public async Task BundleChangelogs(IDiagnosticsCollector collector, Bundle if (filterResult == null) return null; - // Resolve bundle-specific output path and hide-features from profile + // Resolve bundle-specific output path, output products, repo, owner, and hide-features from profile string? outputPath = null; + IReadOnlyList? outputProducts = null; + string? repo = null; + string? owner = null; string[]? mergedHideFeatures = null; if (config?.Bundle?.Profiles != null && config.Bundle.Profiles.TryGetValue(input.Profile!, out var profile)) @@ -237,7 +256,19 @@ public async Task BundleChangelogs(IDiagnosticsCollector collector, Bundle outputPath = _fileSystem.Path.Combine(outputDir, outputPattern); } - mergedHideFeatures = MergeHideFeatures(input.HideFeatures, profile.HideFeatures); + // Parse output_products pattern with version/lifecycle substitution + if (!string.IsNullOrWhiteSpace(profile.OutputProducts)) + { + var lifecycle = VersionLifecycleInference.InferLifecycle(filterResult.Version); + var outputProductsPattern = profile.OutputProducts + .Replace("{version}", filterResult.Version) + .Replace("{lifecycle}", lifecycle); + outputProducts = ProfileFilterResolver.ParseProfileProducts(outputProductsPattern); + } + + repo = profile.Repo; + owner = profile.Owner; + mergedHideFeatures = profile.HideFeatures?.Count > 0 ? [.. profile.HideFeatures] : null; } return input with @@ -245,24 +276,14 @@ public async Task BundleChangelogs(IDiagnosticsCollector collector, Bundle InputProducts = filterResult.Products, Prs = filterResult.Prs, All = false, - Output = outputPath ?? input.Output, - HideFeatures = mergedHideFeatures ?? input.HideFeatures + Output = outputPath, + OutputProducts = outputProducts, + Repo = repo, + Owner = owner, + HideFeatures = mergedHideFeatures }; } - private static string[]? MergeHideFeatures(string[]? cliHideFeatures, IReadOnlyList? profileHideFeatures) - { - if (cliHideFeatures is not { Length: > 0 } && profileHideFeatures is not { Count: > 0 }) - return null; - - var merged = new HashSet(cliHideFeatures ?? [], StringComparer.OrdinalIgnoreCase); - - if (profileHideFeatures is { Count: > 0 }) - merged.UnionWith(profileHideFeatures); - - return merged.Count > 0 ? [.. merged] : null; - } - private static BundleChangelogsArguments ApplyConfigDefaults(BundleChangelogsArguments input, ChangelogConfiguration? config) { // Apply directory: CLI takes precedence. Only use config when --directory not specified. diff --git a/src/services/Elastic.Changelog/Bundling/ChangelogRemoveService.cs b/src/services/Elastic.Changelog/Bundling/ChangelogRemoveService.cs index 31dc8500e..a72915eb7 100644 --- a/src/services/Elastic.Changelog/Bundling/ChangelogRemoveService.cs +++ b/src/services/Elastic.Changelog/Bundling/ChangelogRemoveService.cs @@ -19,7 +19,7 @@ namespace Elastic.Changelog.Bundling; /// public record ChangelogRemoveArguments { - public required string Directory { get; init; } + public string? Directory { get; init; } public bool All { get; init; } public IReadOnlyList? Products { get; init; } public string[]? Prs { get; init; } @@ -62,8 +62,25 @@ public async Task RemoveChangelogs(IDiagnosticsCollector collector, Change { try { + // Load changelog configuration ChangelogConfiguration? config = null; - if (_configLoader != null) + if (!string.IsNullOrWhiteSpace(input.Profile)) + { + // Profile mode requires the config file to exist — no fallback to defaults. + if (_configLoader == null) + { + collector.EmitError(string.Empty, "Changelog configuration loader is required for profile-based removal."); + return false; + } + // When an explicit config path is provided, load it (required, no fallback). + // Otherwise, discover from CWD: ./changelog.yml then ./docs/changelog.yml. + config = string.IsNullOrWhiteSpace(input.Config) + ? await _configLoader.LoadChangelogConfigurationForProfileMode(collector, ctx) + : await _configLoader.LoadChangelogConfigurationRequired(collector, input.Config, ctx); + if (config == null) + return false; + } + else if (_configLoader != null) config = await _configLoader.LoadChangelogConfiguration(collector, input.Config, ctx); // Handle profile-based removal (same ordering as ChangelogBundlingService) @@ -116,13 +133,14 @@ public async Task RemoveChangelogs(IDiagnosticsCollector collector, Change } // A placeholder output path is passed to discovery so the bundle file itself is excluded. - var placeholderOutput = _fileSystem.Path.Combine(input.Directory, "changelog-bundle.yaml"); + // Directory is non-null here: ApplyConfigDefaults ensures a value and ValidateInput enforces non-empty. + var placeholderOutput = _fileSystem.Path.Combine(input.Directory!, "changelog-bundle.yaml"); var fileDiscovery = new ChangelogFileDiscovery(_fileSystem, _logger); - var yamlFiles = await fileDiscovery.DiscoverChangelogFilesAsync(input.Directory, placeholderOutput, ctx); + var yamlFiles = await fileDiscovery.DiscoverChangelogFilesAsync(input.Directory!, placeholderOutput, ctx); if (yamlFiles.Count == 0) { - collector.EmitError(input.Directory, "No changelog YAML files found in directory"); + collector.EmitError(input.Directory!, "No changelog YAML files found in directory"); return false; } @@ -196,14 +214,7 @@ public async Task RemoveChangelogs(IDiagnosticsCollector collector, Change private ChangelogRemoveArguments ApplyConfigDefaults(ChangelogRemoveArguments input, ChangelogConfiguration? config) { - if (config?.Bundle == null) - return input; - - var directory = input.Directory; - if ((string.IsNullOrWhiteSpace(directory) || directory == _fileSystem.Directory.GetCurrentDirectory()) - && !string.IsNullOrWhiteSpace(config.Bundle.Directory)) - directory = config.Bundle.Directory; - + var directory = input.Directory ?? config?.Bundle?.Directory ?? _fileSystem.Directory.GetCurrentDirectory(); return input with { Directory = directory }; } @@ -371,12 +382,13 @@ private async Task> FindBundleDependenciesAsync( } // 3. {directory}/bundles - var sibling = _fileSystem.Path.Combine(input.Directory, "bundles"); + // Directory is guaranteed non-null at this point (ApplyConfigDefaults + ValidateInput). + var sibling = _fileSystem.Path.Combine(input.Directory!, "bundles"); if (_fileSystem.Directory.Exists(sibling)) return sibling; // 4. {directory}/../bundles - var dirParent = _fileSystem.Path.GetDirectoryName(input.Directory); + var dirParent = _fileSystem.Path.GetDirectoryName(input.Directory!); if (!string.IsNullOrWhiteSpace(dirParent)) { var parentBundles = _fileSystem.Path.Combine(dirParent, "bundles"); diff --git a/src/services/Elastic.Changelog/Configuration/ChangelogConfigurationLoader.cs b/src/services/Elastic.Changelog/Configuration/ChangelogConfigurationLoader.cs index 1b3daecc6..9ed6401ff 100644 --- a/src/services/Elastic.Changelog/Configuration/ChangelogConfigurationLoader.cs +++ b/src/services/Elastic.Changelog/Configuration/ChangelogConfigurationLoader.cs @@ -410,6 +410,9 @@ private static BundleConfiguration ParseBundleConfiguration(BundleConfigurationY { Products = kvp.Value.Products, Output = kvp.Value.Output, + OutputProducts = kvp.Value.OutputProducts, + Repo = kvp.Value.Repo, + Owner = kvp.Value.Owner, HideFeatures = kvp.Value.HideFeatures?.Values }); } @@ -423,6 +426,98 @@ private static BundleConfiguration ParseBundleConfiguration(BundleConfigurationY }; } + /// + /// Loads changelog configuration from a specific path, treating a missing file as a hard error. + /// Used in profile mode when an explicit config path was provided (e.g. in tests). + /// + public async Task LoadChangelogConfigurationRequired(IDiagnosticsCollector collector, string configPath, Cancel ctx) + { + if (!fileSystem.File.Exists(configPath)) + { + collector.EmitError( + configPath, + $"Changelog configuration file not found at '{configPath}'. " + + "Either run 'docs-builder changelog init' to create one, " + + "or re-run from the folder where changelog.yml exists." + ); + return null; + } + + try + { + var yamlContent = await fileSystem.File.ReadAllTextAsync(configPath, ctx); + var yamlConfig = DeserializeConfiguration(yamlContent); + return ParseConfiguration(collector, yamlConfig, configPath); + } + catch (IOException ex) + { + collector.EmitError(configPath, $"I/O error loading changelog configuration: {ex.Message}", ex); + return null; + } + catch (UnauthorizedAccessException ex) + { + collector.EmitError(configPath, $"Access denied loading changelog configuration: {ex.Message}", ex); + return null; + } + catch (YamlDotNet.Core.YamlException ex) + { + collector.EmitError(configPath, $"YAML parsing error in changelog configuration: {ex.Message}", ex); + return null; + } + } + + /// + /// Discovers and loads the changelog configuration for profile mode. + /// Unlike , this method treats a missing config file as a + /// hard error. It searches for changelog.yml then docs/changelog.yml relative to the + /// current working directory, so the command works when run from any folder that contains the file. + /// + public async Task LoadChangelogConfigurationForProfileMode(IDiagnosticsCollector collector, Cancel ctx) + { + var cwd = fileSystem.Directory.GetCurrentDirectory(); + var candidates = new[] + { + fileSystem.Path.Combine(cwd, "changelog.yml"), + fileSystem.Path.Combine(cwd, "docs", "changelog.yml") + }; + + var foundPath = candidates.FirstOrDefault(fileSystem.File.Exists); + + if (foundPath == null) + { + collector.EmitError( + string.Empty, + "changelog.yml not found. Profile-based commands require a changelog configuration file. " + + "Either run 'docs-builder changelog init' to create one, " + + "or re-run this command from the folder where changelog.yml exists " + + "(e.g. the project root if the file is at docs/changelog.yml)." + ); + return null; + } + + try + { + var yamlContent = await fileSystem.File.ReadAllTextAsync(foundPath, ctx); + var yamlConfig = DeserializeConfiguration(yamlContent); + return ParseConfiguration(collector, yamlConfig, foundPath); + } + catch (IOException ex) + { + collector.EmitError(foundPath, $"I/O error loading changelog configuration: {ex.Message}", ex); + return null; + } + catch (UnauthorizedAccessException ex) + { + collector.EmitError(foundPath, $"Access denied loading changelog configuration: {ex.Message}", ex); + return null; + } + catch (YamlDotNet.Core.YamlException ex) + { + collector.EmitError(foundPath, $"YAML parsing error in changelog configuration: {ex.Message}", ex); + return null; + } + } + private RulesConfiguration? ParseRulesConfiguration( IDiagnosticsCollector collector, RulesConfigurationYaml? rulesYaml, diff --git a/src/services/Elastic.Changelog/Serialization/ChangelogConfigurationYaml.cs b/src/services/Elastic.Changelog/Serialization/ChangelogConfigurationYaml.cs index f6c6555da..3d0da628e 100644 --- a/src/services/Elastic.Changelog/Serialization/ChangelogConfigurationYaml.cs +++ b/src/services/Elastic.Changelog/Serialization/ChangelogConfigurationYaml.cs @@ -235,6 +235,23 @@ internal record BundleProfileYaml /// public string? Output { get; set; } + /// + /// Output products pattern. Overrides the products array derived from matched changelogs. + /// Supports {version} and {lifecycle} placeholders. + /// + public string? OutputProducts { get; set; } + + /// + /// GitHub repository name for generating PR/issue links in bundle output. + /// + public string? Repo { get; set; } + + /// + /// GitHub repository owner for generating PR/issue links in bundle output. + /// Defaults to "elastic" when not specified. + /// + public string? Owner { get; set; } + /// /// Feature IDs to mark as hidden in the bundle output (string or list). /// diff --git a/src/tooling/docs-builder/Commands/ChangelogCommand.cs b/src/tooling/docs-builder/Commands/ChangelogCommand.cs index 4b4da38b4..01c65fde5 100644 --- a/src/tooling/docs-builder/Commands/ChangelogCommand.cs +++ b/src/tooling/docs-builder/Commands/ChangelogCommand.cs @@ -477,48 +477,68 @@ public async Task Bundle( } } - // In raw mode (no profile), validate filter options - if (!isProfileMode) + // Validate filter/output options against profile mode + if (isProfileMode) + { + var forbidden = new List(); + if (all) forbidden.Add("--all"); + if (inputProducts is { Count: > 0 }) forbidden.Add("--input-products"); + if (outputProducts is { Count: > 0 }) forbidden.Add("--output-products"); + if (allPrs.Count > 0) forbidden.Add("--prs"); + if (allIssues.Count > 0) forbidden.Add("--issues"); + if (!string.IsNullOrWhiteSpace(output)) forbidden.Add("--output"); + if (!string.IsNullOrWhiteSpace(repo)) forbidden.Add("--repo"); + if (!string.IsNullOrWhiteSpace(owner)) forbidden.Add("--owner"); + if (resolve.HasValue) forbidden.Add("--resolve"); + if (noResolve) forbidden.Add("--no-resolve"); + if (hideFeatures is { Length: > 0 }) forbidden.Add("--hide-features"); + if (!string.IsNullOrWhiteSpace(config)) forbidden.Add("--config"); + if (!string.IsNullOrWhiteSpace(directory)) forbidden.Add("--directory"); + + if (forbidden.Count > 0) { - var specifiedFilters = new List(); - if (all) - specifiedFilters.Add("--all"); - if (inputProducts != null && inputProducts.Count > 0) - specifiedFilters.Add("--input-products"); - if (allPrs.Count > 0) - specifiedFilters.Add("--prs"); - if (allIssues.Count > 0) - specifiedFilters.Add("--issues"); - - if (specifiedFilters.Count == 0) - { - collector.EmitError(string.Empty, "At least one filter option must be specified: --all, --input-products, --prs, --issues, or use a profile (e.g., 'bundle elasticsearch-release 9.2.0')"); - _ = collector.StartAsync(ctx); - await collector.WaitForDrain(); - await collector.StopAsync(ctx); - return 1; - } + collector.EmitError( + string.Empty, + $"When using a profile, the following options are not allowed: {string.Join(", ", forbidden)}. " + + "All paths and filters are derived from the changelog configuration file." + ); + _ = collector.StartAsync(ctx); + await collector.WaitForDrain(); + await collector.StopAsync(ctx); + return 1; + } + } + else + { + // Raw mode: require exactly one filter option + var specifiedFilters = new List(); + if (all) + specifiedFilters.Add("--all"); + if (inputProducts != null && inputProducts.Count > 0) + specifiedFilters.Add("--input-products"); + if (allPrs.Count > 0) + specifiedFilters.Add("--prs"); + if (allIssues.Count > 0) + specifiedFilters.Add("--issues"); - if (specifiedFilters.Count > 1) - { - collector.EmitError(string.Empty, $"Multiple filter options cannot be specified together. You specified: {string.Join(", ", specifiedFilters)}. Please use only one filter option: --all, --input-products, --prs, or --issues"); - _ = collector.StartAsync(ctx); - await collector.WaitForDrain(); - await collector.StopAsync(ctx); - return 1; - } + if (specifiedFilters.Count == 0) + { + collector.EmitError(string.Empty, "At least one filter option must be specified: --all, --input-products, --prs, --issues, or use a profile (e.g., 'bundle elasticsearch-release 9.2.0')"); + _ = collector.StartAsync(ctx); + await collector.WaitForDrain(); + await collector.StopAsync(ctx); + return 1; } - else + + if (specifiedFilters.Count > 1) { - if (all || (inputProducts != null && inputProducts.Count > 0) || allPrs.Count > 0 || allIssues.Count > 0) - { - collector.EmitError(string.Empty, "When using a profile, do not specify --all, --input-products, --prs, or --issues. The profile configuration determines the filter."); - _ = collector.StartAsync(ctx); - await collector.WaitForDrain(); - await collector.StopAsync(ctx); - return 1; - } + collector.EmitError(string.Empty, $"Multiple filter options cannot be specified together. You specified: {string.Join(", ", specifiedFilters)}. Please use only one filter option: --all, --input-products, --prs, or --issues"); + _ = collector.StartAsync(ctx); + await collector.WaitForDrain(); + await collector.StopAsync(ctx); + return 1; } + } // Validate that if inputProducts is provided, all three parts (product, target, lifecycle) are present for each entry // They can be wildcards (*) but must be present @@ -720,30 +740,46 @@ public async Task Remove( } } - if (isProfileMode) + if (isProfileMode) + { + // Profile mode: reject all options except --dry-run and --force + var forbidden = new List(); + if (all) forbidden.Add("--all"); + if (products is { Count: > 0 }) forbidden.Add("--products"); + if (allPrs.Count > 0) forbidden.Add("--prs"); + if (allIssues.Count > 0) forbidden.Add("--issues"); + if (!string.IsNullOrWhiteSpace(repo)) forbidden.Add("--repo"); + if (!string.IsNullOrWhiteSpace(owner)) forbidden.Add("--owner"); + if (!string.IsNullOrWhiteSpace(config)) forbidden.Add("--config"); + if (!string.IsNullOrWhiteSpace(directory)) forbidden.Add("--directory"); + if (!string.IsNullOrWhiteSpace(bundlesDir)) forbidden.Add("--bundles-dir"); + + if (forbidden.Count > 0) { - // Profile mode: --all, --products, --prs, and --issues must not be used - if (all || (products != null && products.Count > 0) || allPrs.Count > 0 || allIssues.Count > 0) - { - collector.EmitError(string.Empty, "When using a profile, do not specify --all, --products, --prs, or --issues. The profile configuration determines the filter."); - _ = collector.StartAsync(ctx); - await collector.WaitForDrain(); - await collector.StopAsync(ctx); - return 1; - } - - // profileArg is required when profile is specified - if (string.IsNullOrWhiteSpace(profileArg)) - { - collector.EmitError(string.Empty, $"Profile '{profile}' requires a version number or promotion report URL as the second argument"); - _ = collector.StartAsync(ctx); - await collector.WaitForDrain(); - await collector.StopAsync(ctx); - return 1; - } + collector.EmitError( + string.Empty, + $"When using a profile, the following options are not allowed: {string.Join(", ", forbidden)}. " + + "All paths and filters are derived from the changelog configuration file. " + + "Allowed options with profiles: --dry-run, --force." + ); + _ = collector.StartAsync(ctx); + await collector.WaitForDrain(); + await collector.StopAsync(ctx); + return 1; } - else + + // profileArg is required when profile is specified + if (string.IsNullOrWhiteSpace(profileArg)) { + collector.EmitError(string.Empty, $"Profile '{profile}' requires a version number or promotion report URL as the second argument"); + _ = collector.StartAsync(ctx); + await collector.WaitForDrain(); + await collector.StopAsync(ctx); + return 1; + } + } + else + { // Raw mode: validate product filter parts and apply wildcard shortcut if (products is { Count: > 0 }) { @@ -791,22 +827,26 @@ public async Task Remove( } } - var input = new ChangelogRemoveArguments - { - Directory = NormalizePath(directory ?? Directory.GetCurrentDirectory()), - All = all, - Products = products, - Prs = allPrs.Count > 0 ? allPrs.ToArray() : null, - Issues = allIssues.Count > 0 ? allIssues.ToArray() : null, - Owner = owner, - Repo = repo, - DryRun = dryRun, - BundlesDir = string.IsNullOrWhiteSpace(bundlesDir) ? null : NormalizePath(bundlesDir), - Force = force, - Config = string.IsNullOrWhiteSpace(config) ? null : NormalizePath(config), - Profile = isProfileMode ? profile : null, - ProfileArgument = isProfileMode ? profileArg : null - }; + // In profile mode, directory is derived from the changelog config (not from CLI). + // In raw mode, use --directory if provided, falling back to cwd (for the service's ApplyConfigDefaults). + var resolvedDirectory = isProfileMode ? null : NormalizePath(directory ?? Directory.GetCurrentDirectory()); + + var input = new ChangelogRemoveArguments + { + Directory = resolvedDirectory, + All = all, + Products = products, + Prs = allPrs.Count > 0 ? allPrs.ToArray() : null, + Issues = allIssues.Count > 0 ? allIssues.ToArray() : null, + Owner = owner, + Repo = repo, + DryRun = dryRun, + BundlesDir = string.IsNullOrWhiteSpace(bundlesDir) ? null : NormalizePath(bundlesDir), + Force = force, + Config = string.IsNullOrWhiteSpace(config) ? null : NormalizePath(config), + Profile = isProfileMode ? profile : null, + ProfileArgument = isProfileMode ? profileArg : null + }; serviceInvoker.AddCommand(service, input, async static (s, collector, state, ctx) => await s.RemoveChangelogs(collector, state, ctx) diff --git a/tests/Elastic.Changelog.Tests/Changelogs/BundleChangelogsTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/BundleChangelogsTests.cs index 6fa23d772..e01de260d 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/BundleChangelogsTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/BundleChangelogsTests.cs @@ -2264,9 +2264,11 @@ public async Task BundleChangelogs_WithProfileHideFeatures_IncludesHideFeaturesI } [Fact] - public async Task BundleChangelogs_WithProfileAndCliHideFeatures_MergesBothSources() + public async Task BundleChangelogs_WithProfile_OnlyProfileHideFeaturesAreUsed() { - // Arrange - Test that CLI --hide-features and profile hide_features are merged + // Arrange - In profile mode, only hide_features from the profile config are written to the bundle. + // Any HideFeatures passed directly to the service are ignored (the CLI now rejects --hide-features + // in profile mode, so this combination is not reachable from the command layer). // language=yaml var configContent = @@ -2309,8 +2311,7 @@ public async Task BundleChangelogs_WithProfileAndCliHideFeatures_MergesBothSourc Profile = "es-release", ProfileArgument = "9.2.0", Config = configPath, - OutputDirectory = outputDir, - HideFeatures = ["feature:from-cli"] + OutputDirectory = outputDir }; // Act @@ -2325,16 +2326,15 @@ public async Task BundleChangelogs_WithProfileAndCliHideFeatures_MergesBothSourc outputFiles.Should().NotBeEmpty("Expected an output file to be created"); var bundleContent = await FileSystem.File.ReadAllTextAsync(outputFiles[0], TestContext.Current.CancellationToken); - // Verify that hide-features from BOTH sources are present + // Verify that only the profile hide-features are present bundleContent.Should().Contain("hide-features:"); bundleContent.Should().Contain("- feature:from-profile"); - bundleContent.Should().Contain("- feature:from-cli"); } [Fact] - public async Task BundleChangelogs_WithProfileAndCliHideFeatures_DeduplicatesFeatureIds() + public async Task BundleChangelogs_WithProfileMultipleHideFeatures_AllProfileFeaturesArePresent() { - // Arrange - Test that duplicate feature IDs from CLI and profile are deduplicated + // Arrange - All hide_features from the profile are written to the bundle // language=yaml var configContent = @@ -2345,8 +2345,8 @@ public async Task BundleChangelogs_WithProfileAndCliHideFeatures_DeduplicatesFea products: "elasticsearch {version} {lifecycle}" output: "elasticsearch-{version}.yaml" hide_features: - - feature:shared - - feature:profile-only + - feature:profile-one + - feature:profile-two """; var configPath = FileSystem.Path.Combine(FileSystem.Path.GetTempPath(), "config3", "changelog.yml"); @@ -2378,8 +2378,7 @@ public async Task BundleChangelogs_WithProfileAndCliHideFeatures_DeduplicatesFea Profile = "es-release", ProfileArgument = "9.2.0", Config = configPath, - OutputDirectory = outputDir, - HideFeatures = ["feature:shared", "feature:cli-only"] // "feature:shared" overlaps with profile + OutputDirectory = outputDir }; // Act @@ -2393,14 +2392,8 @@ public async Task BundleChangelogs_WithProfileAndCliHideFeatures_DeduplicatesFea outputFiles.Should().NotBeEmpty("Expected an output file to be created"); var bundleContent = await FileSystem.File.ReadAllTextAsync(outputFiles[0], TestContext.Current.CancellationToken); - // Verify all unique features are present - bundleContent.Should().Contain("- feature:shared"); - bundleContent.Should().Contain("- feature:profile-only"); - bundleContent.Should().Contain("- feature:cli-only"); - - // Count occurrences of "feature:shared" - should appear exactly once (deduplicated) - var sharedCount = bundleContent.Split("feature:shared").Length - 1; - sharedCount.Should().Be(1, "Duplicate feature IDs should be deduplicated"); + bundleContent.Should().Contain("- feature:profile-one"); + bundleContent.Should().Contain("- feature:profile-two"); } [Fact] From eb59f29ff4242bea4b318ed5ddf2e5e5c9aafb19 Mon Sep 17 00:00:00 2001 From: lcawl Date: Wed, 25 Feb 2026 16:00:31 -0800 Subject: [PATCH 2/9] Lint --- .../docs-builder/Commands/ChangelogCommand.cs | 248 ++++++++++-------- 1 file changed, 135 insertions(+), 113 deletions(-) diff --git a/src/tooling/docs-builder/Commands/ChangelogCommand.cs b/src/tooling/docs-builder/Commands/ChangelogCommand.cs index 01c65fde5..2d90fdd26 100644 --- a/src/tooling/docs-builder/Commands/ChangelogCommand.cs +++ b/src/tooling/docs-builder/Commands/ChangelogCommand.cs @@ -477,68 +477,81 @@ public async Task Bundle( } } - // Validate filter/output options against profile mode - if (isProfileMode) - { - var forbidden = new List(); - if (all) forbidden.Add("--all"); - if (inputProducts is { Count: > 0 }) forbidden.Add("--input-products"); - if (outputProducts is { Count: > 0 }) forbidden.Add("--output-products"); - if (allPrs.Count > 0) forbidden.Add("--prs"); - if (allIssues.Count > 0) forbidden.Add("--issues"); - if (!string.IsNullOrWhiteSpace(output)) forbidden.Add("--output"); - if (!string.IsNullOrWhiteSpace(repo)) forbidden.Add("--repo"); - if (!string.IsNullOrWhiteSpace(owner)) forbidden.Add("--owner"); - if (resolve.HasValue) forbidden.Add("--resolve"); - if (noResolve) forbidden.Add("--no-resolve"); - if (hideFeatures is { Length: > 0 }) forbidden.Add("--hide-features"); - if (!string.IsNullOrWhiteSpace(config)) forbidden.Add("--config"); - if (!string.IsNullOrWhiteSpace(directory)) forbidden.Add("--directory"); - - if (forbidden.Count > 0) + // Validate filter/output options against profile mode + if (isProfileMode) { - collector.EmitError( - string.Empty, - $"When using a profile, the following options are not allowed: {string.Join(", ", forbidden)}. " + - "All paths and filters are derived from the changelog configuration file." - ); - _ = collector.StartAsync(ctx); - await collector.WaitForDrain(); - await collector.StopAsync(ctx); - return 1; + var forbidden = new List(); + if (all) + forbidden.Add("--all"); + if (inputProducts is { Count: > 0 }) + forbidden.Add("--input-products"); + if (outputProducts is { Count: > 0 }) + forbidden.Add("--output-products"); + if (allPrs.Count > 0) + forbidden.Add("--prs"); + if (allIssues.Count > 0) + forbidden.Add("--issues"); + if (!string.IsNullOrWhiteSpace(output)) + forbidden.Add("--output"); + if (!string.IsNullOrWhiteSpace(repo)) + forbidden.Add("--repo"); + if (!string.IsNullOrWhiteSpace(owner)) + forbidden.Add("--owner"); + if (resolve.HasValue) + forbidden.Add("--resolve"); + if (noResolve) + forbidden.Add("--no-resolve"); + if (hideFeatures is { Length: > 0 }) + forbidden.Add("--hide-features"); + if (!string.IsNullOrWhiteSpace(config)) + forbidden.Add("--config"); + if (!string.IsNullOrWhiteSpace(directory)) + forbidden.Add("--directory"); + + if (forbidden.Count > 0) + { + collector.EmitError( + string.Empty, + $"When using a profile, the following options are not allowed: {string.Join(", ", forbidden)}. " + + "All paths and filters are derived from the changelog configuration file." + ); + _ = collector.StartAsync(ctx); + await collector.WaitForDrain(); + await collector.StopAsync(ctx); + return 1; + } } - } - else - { - // Raw mode: require exactly one filter option - var specifiedFilters = new List(); - if (all) - specifiedFilters.Add("--all"); - if (inputProducts != null && inputProducts.Count > 0) - specifiedFilters.Add("--input-products"); - if (allPrs.Count > 0) - specifiedFilters.Add("--prs"); - if (allIssues.Count > 0) - specifiedFilters.Add("--issues"); - - if (specifiedFilters.Count == 0) + else { - collector.EmitError(string.Empty, "At least one filter option must be specified: --all, --input-products, --prs, --issues, or use a profile (e.g., 'bundle elasticsearch-release 9.2.0')"); - _ = collector.StartAsync(ctx); - await collector.WaitForDrain(); - await collector.StopAsync(ctx); - return 1; - } + // Raw mode: require exactly one filter option + var specifiedFilters = new List(); + if (all) + specifiedFilters.Add("--all"); + if (inputProducts != null && inputProducts.Count > 0) + specifiedFilters.Add("--input-products"); + if (allPrs.Count > 0) + specifiedFilters.Add("--prs"); + if (allIssues.Count > 0) + specifiedFilters.Add("--issues"); + + if (specifiedFilters.Count == 0) + { + collector.EmitError(string.Empty, "At least one filter option must be specified: --all, --input-products, --prs, --issues, or use a profile (e.g., 'bundle elasticsearch-release 9.2.0')"); + _ = collector.StartAsync(ctx); + await collector.WaitForDrain(); + await collector.StopAsync(ctx); + return 1; + } - if (specifiedFilters.Count > 1) - { - collector.EmitError(string.Empty, $"Multiple filter options cannot be specified together. You specified: {string.Join(", ", specifiedFilters)}. Please use only one filter option: --all, --input-products, --prs, or --issues"); - _ = collector.StartAsync(ctx); - await collector.WaitForDrain(); - await collector.StopAsync(ctx); - return 1; + if (specifiedFilters.Count > 1) + { + collector.EmitError(string.Empty, $"Multiple filter options cannot be specified together. You specified: {string.Join(", ", specifiedFilters)}. Please use only one filter option: --all, --input-products, --prs, or --issues"); + _ = collector.StartAsync(ctx); + await collector.WaitForDrain(); + await collector.StopAsync(ctx); + return 1; + } } - } // Validate that if inputProducts is provided, all three parts (product, target, lifecycle) are present for each entry // They can be wildcards (*) but must be present @@ -740,46 +753,55 @@ public async Task Remove( } } - if (isProfileMode) - { - // Profile mode: reject all options except --dry-run and --force - var forbidden = new List(); - if (all) forbidden.Add("--all"); - if (products is { Count: > 0 }) forbidden.Add("--products"); - if (allPrs.Count > 0) forbidden.Add("--prs"); - if (allIssues.Count > 0) forbidden.Add("--issues"); - if (!string.IsNullOrWhiteSpace(repo)) forbidden.Add("--repo"); - if (!string.IsNullOrWhiteSpace(owner)) forbidden.Add("--owner"); - if (!string.IsNullOrWhiteSpace(config)) forbidden.Add("--config"); - if (!string.IsNullOrWhiteSpace(directory)) forbidden.Add("--directory"); - if (!string.IsNullOrWhiteSpace(bundlesDir)) forbidden.Add("--bundles-dir"); - - if (forbidden.Count > 0) + if (isProfileMode) { - collector.EmitError( - string.Empty, - $"When using a profile, the following options are not allowed: {string.Join(", ", forbidden)}. " + - "All paths and filters are derived from the changelog configuration file. " + - "Allowed options with profiles: --dry-run, --force." - ); - _ = collector.StartAsync(ctx); - await collector.WaitForDrain(); - await collector.StopAsync(ctx); - return 1; - } + // Profile mode: reject all options except --dry-run and --force + var forbidden = new List(); + if (all) + forbidden.Add("--all"); + if (products is { Count: > 0 }) + forbidden.Add("--products"); + if (allPrs.Count > 0) + forbidden.Add("--prs"); + if (allIssues.Count > 0) + forbidden.Add("--issues"); + if (!string.IsNullOrWhiteSpace(repo)) + forbidden.Add("--repo"); + if (!string.IsNullOrWhiteSpace(owner)) + forbidden.Add("--owner"); + if (!string.IsNullOrWhiteSpace(config)) + forbidden.Add("--config"); + if (!string.IsNullOrWhiteSpace(directory)) + forbidden.Add("--directory"); + if (!string.IsNullOrWhiteSpace(bundlesDir)) + forbidden.Add("--bundles-dir"); + + if (forbidden.Count > 0) + { + collector.EmitError( + string.Empty, + $"When using a profile, the following options are not allowed: {string.Join(", ", forbidden)}. " + + "All paths and filters are derived from the changelog configuration file. " + + "Allowed options with profiles: --dry-run, --force." + ); + _ = collector.StartAsync(ctx); + await collector.WaitForDrain(); + await collector.StopAsync(ctx); + return 1; + } - // profileArg is required when profile is specified - if (string.IsNullOrWhiteSpace(profileArg)) - { - collector.EmitError(string.Empty, $"Profile '{profile}' requires a version number or promotion report URL as the second argument"); - _ = collector.StartAsync(ctx); - await collector.WaitForDrain(); - await collector.StopAsync(ctx); - return 1; + // profileArg is required when profile is specified + if (string.IsNullOrWhiteSpace(profileArg)) + { + collector.EmitError(string.Empty, $"Profile '{profile}' requires a version number or promotion report URL as the second argument"); + _ = collector.StartAsync(ctx); + await collector.WaitForDrain(); + await collector.StopAsync(ctx); + return 1; + } } - } - else - { + else + { // Raw mode: validate product filter parts and apply wildcard shortcut if (products is { Count: > 0 }) { @@ -827,26 +849,26 @@ public async Task Remove( } } - // In profile mode, directory is derived from the changelog config (not from CLI). - // In raw mode, use --directory if provided, falling back to cwd (for the service's ApplyConfigDefaults). - var resolvedDirectory = isProfileMode ? null : NormalizePath(directory ?? Directory.GetCurrentDirectory()); + // In profile mode, directory is derived from the changelog config (not from CLI). + // In raw mode, use --directory if provided, falling back to cwd (for the service's ApplyConfigDefaults). + var resolvedDirectory = isProfileMode ? null : NormalizePath(directory ?? Directory.GetCurrentDirectory()); - var input = new ChangelogRemoveArguments - { - Directory = resolvedDirectory, - All = all, - Products = products, - Prs = allPrs.Count > 0 ? allPrs.ToArray() : null, - Issues = allIssues.Count > 0 ? allIssues.ToArray() : null, - Owner = owner, - Repo = repo, - DryRun = dryRun, - BundlesDir = string.IsNullOrWhiteSpace(bundlesDir) ? null : NormalizePath(bundlesDir), - Force = force, - Config = string.IsNullOrWhiteSpace(config) ? null : NormalizePath(config), - Profile = isProfileMode ? profile : null, - ProfileArgument = isProfileMode ? profileArg : null - }; + var input = new ChangelogRemoveArguments + { + Directory = resolvedDirectory, + All = all, + Products = products, + Prs = allPrs.Count > 0 ? allPrs.ToArray() : null, + Issues = allIssues.Count > 0 ? allIssues.ToArray() : null, + Owner = owner, + Repo = repo, + DryRun = dryRun, + BundlesDir = string.IsNullOrWhiteSpace(bundlesDir) ? null : NormalizePath(bundlesDir), + Force = force, + Config = string.IsNullOrWhiteSpace(config) ? null : NormalizePath(config), + Profile = isProfileMode ? profile : null, + ProfileArgument = isProfileMode ? profileArg : null + }; serviceInvoker.AddCommand(service, input, async static (s, collector, state, ctx) => await s.RemoveChangelogs(collector, state, ctx) From 3e3a93ea5d7ec660f5376d50e0dfde254c3fca42 Mon Sep 17 00:00:00 2001 From: lcawl Date: Wed, 25 Feb 2026 16:22:26 -0800 Subject: [PATCH 3/9] Add Phase 5 tests and docs for profile parity - Add 7 new unit tests for bundle/remove profile features: - output_products overrides products array in bundle - repo from profile is written to bundle product entries - no repo/owner in profile preserves existing fallback behaviour - missing config in profile mode returns error with advice (bundle + remove) - changelog.yml discovered from CWD (./changelog.yml) - changelog.yml discovered from docs/ subdir (./docs/changelog.yml) - Update docs/contribute/changelog.md: - Document mutual exclusivity of profile-based vs option-based bundle usage - Add new "Profile-based bundling" section with full field reference table - Document config auto-discovery behaviour for profile mode - Update "Removal with profiles" to document mutual exclusivity, allowed --dry-run/--force exceptions, and which profile fields are ignored Made-with: Cursor --- docs/contribute/changelog.md | 61 +++- .../Changelogs/BundleChangelogsTests.cs | 344 ++++++++++++++++++ .../Changelogs/ChangelogRemoveTests.cs | 31 ++ 3 files changed, 435 insertions(+), 1 deletion(-) diff --git a/docs/contribute/changelog.md b/docs/contribute/changelog.md index e1ebae696..1a8bae548 100644 --- a/docs/contribute/changelog.md +++ b/docs/contribute/changelog.md @@ -483,6 +483,15 @@ This creates one changelog file for each PR specified, whether from files or dir You can use the `docs-builder changelog bundle` command to create a YAML file that lists multiple changelogs. For up-to-date details, use the `-h` option or refer to [](/cli/release/changelog-bundle.md). +The command supports two mutually exclusive usage modes: + +- **Option-based** — you provide filter and output options directly on the command line. +- **Profile-based** — you specify a named profile from your `changelog.yml` configuration file. + +You cannot mix these two modes: when you use a profile name, no filter or output options are accepted on the command line. + +### Option-based bundling [changelog-bundle-options] + You can specify only one of the following filter options: - `--all`: Include all changelogs from the directory. @@ -501,6 +510,50 @@ If you likewise want to regenerate your [Asciidoc or Markdown files](#render-cha When you do not specify `--directory`, the command reads changelog files from `bundle.directory` in your changelog configuration if it is set, otherwise from the current directory. When you do not specify `--output`, the command writes the bundle to `bundle.output_directory` from your changelog configuration (creating `changelog-bundle.yaml` in that directory) if it is set, otherwise to `changelog-bundle.yaml` in the input directory. +### Profile-based bundling [changelog-bundle-profile] + +If your `changelog.yml` configuration file defines `bundle.profiles`, you can run a bundle by profile name instead of supplying individual options: + +```sh +docs-builder changelog bundle +``` + +For example: + +```sh +docs-builder changelog bundle elasticsearch-release 9.2.0 +docs-builder changelog bundle elasticsearch-release ./promotion-report.html +``` + +The command automatically discovers `changelog.yml` by checking `./changelog.yml` then `./docs/changelog.yml` relative to your current directory. +If no configuration file is found, the command returns an error with advice to create one (using `docs-builder changelog init`) or to run from the directory where the file exists. + +Profile configuration fields in the `bundle.profiles` section: + +| Field | Description | +|---|---| +| `products` | Product filter pattern with `{version}` and `{lifecycle}` placeholders. Used to match changelog files. | +| `output` | Output file path pattern with `{version}` and `{lifecycle}` placeholders. | +| `output_products` | Optional override for the products array written to the bundle. Useful when the bundle should advertise a different lifecycle or version than the filter. | +| `repo` | GitHub repository name written to each product entry. Used to generate correct PR and issue links when the product ID differs from the repository name. | +| `owner` | GitHub repository owner. Used for normalizing short PR and issue references. | +| `hide_features` | List of feature IDs to embed in the bundle as hidden. | + +Example profile configuration: + +```yaml +bundle: + profiles: + elasticsearch-release: + products: "elasticsearch {version} {lifecycle}" + output: "bundles/elasticsearch-{version}.yaml" + output_products: "elasticsearch {version} ga" + repo: elasticsearch + owner: elastic + hide_features: + - feature:experimental-api +``` + ### Filter by product [changelog-bundle-product] You can use the `--input-products` option to create a bundle of changelogs that match the product details. @@ -868,8 +921,14 @@ You can remove the same changelogs with: docs-builder changelog remove elasticsearch-release 9.2.0 --dry-run ``` +The command automatically discovers `changelog.yml` by checking `./changelog.yml` then `./docs/changelog.yml` relative to your current directory. +If no configuration file is found, the command returns an error with advice to create one or to run from the directory where the file exists. + Only the `products` field from the profile configuration is used for removal. -The `output` and `hide_features` fields are bundle-specific and are ignored. +The `output`, `output_products`, `repo`, `owner`, and `hide_features` fields are bundle-specific and are ignored. + +Profile-based removal is mutually exclusive with command options. +The only options allowed alongside a profile name are `--dry-run` and `--force`. You can also pass a promotion report URL or file path as the second argument, and the command removes changelogs whose pull request URLs appear in the report: diff --git a/tests/Elastic.Changelog.Tests/Changelogs/BundleChangelogsTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/BundleChangelogsTests.cs index e01de260d..4c839138b 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/BundleChangelogsTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/BundleChangelogsTests.cs @@ -2730,6 +2730,350 @@ public async Task AmendBundle_WithResolve_ProducesNormalizedChecksum() amendContent.Should().Contain("type: feature"); } + [Fact] + public async Task BundleChangelogs_WithProfile_OutputProducts_OverridesProductsArray() + { + // Arrange - output_products overrides the products array written to the bundle. + // The profile uses a wildcard lifecycle to match any changelog for the given version, + // while output_products pins the lifecycle advertised in the bundle output to "ga". + + // language=yaml + var configContent = + """ + bundle: + profiles: + es-release: + products: "elasticsearch {version} *" + output: "elasticsearch-{version}.yaml" + output_products: "elasticsearch {version} ga" + """; + + var configPath = FileSystem.Path.Combine(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "changelog.yml"); + FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); + await FileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); + + // language=yaml + var changelog1 = + """ + title: Elasticsearch feature + type: feature + products: + - product: elasticsearch + target: 9.2.0 + lifecycle: preview + prs: + - https://github.com/elastic/elasticsearch/pull/100 + """; + + var file1 = FileSystem.Path.Combine(_changelogDir, "1755268130-feature.yaml"); + await FileSystem.File.WriteAllTextAsync(file1, changelog1, TestContext.Current.CancellationToken); + + var outputDir = FileSystem.Path.Combine(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + FileSystem.Directory.CreateDirectory(outputDir); + + var input = new BundleChangelogsArguments + { + Directory = _changelogDir, + Profile = "es-release", + ProfileArgument = "9.2.0", + Config = configPath, + OutputDirectory = outputDir + }; + + // Act + var result = await ServiceWithConfig.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeTrue($"Expected bundling to succeed, but got errors: {string.Join("; ", Collector.Diagnostics.Select(d => d.Message))}"); + Collector.Errors.Should().Be(0); + + var outputFiles = FileSystem.Directory.GetFiles(outputDir, "*.yaml"); + outputFiles.Should().NotBeEmpty("Expected an output file to be created"); + var bundleContent = await FileSystem.File.ReadAllTextAsync(outputFiles[0], TestContext.Current.CancellationToken); + + // output_products overrides: the products array in the bundle output should have lifecycle: ga + // even though the matched changelog has lifecycle: preview + bundleContent.Should().Contain("lifecycle: ga", "output_products should write lifecycle: ga to the bundle products array"); + } + + [Fact] + public async Task BundleChangelogs_WithProfile_RepoAndOwner_WritesValuesToProductEntries() + { + // Arrange - repo and owner in the profile are written to each product entry in the bundle. + // Note: date-based versions like "2025-06-01" contain dashes that InferLifecycle treats as + // a semver prerelease, so we use a wildcard lifecycle in the products pattern to match any + // lifecycle value present in the changelog files. + + // language=yaml + var configContent = + """ + bundle: + profiles: + serverless-release: + products: "cloud-serverless {version} *" + output: "serverless-{version}.yaml" + repo: cloud + owner: elastic + """; + + var configPath = FileSystem.Path.Combine(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "changelog.yml"); + FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); + await FileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); + + // language=yaml + var changelog1 = + """ + title: Serverless feature + type: feature + products: + - product: cloud-serverless + target: 2025-06-01 + lifecycle: ga + prs: + - https://github.com/elastic/cloud/pull/200 + """; + + var file1 = FileSystem.Path.Combine(_changelogDir, "1755268130-serverless-feature.yaml"); + await FileSystem.File.WriteAllTextAsync(file1, changelog1, TestContext.Current.CancellationToken); + + var outputDir = FileSystem.Path.Combine(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + FileSystem.Directory.CreateDirectory(outputDir); + + var input = new BundleChangelogsArguments + { + Directory = _changelogDir, + Profile = "serverless-release", + ProfileArgument = "2025-06-01", + Config = configPath, + OutputDirectory = outputDir + }; + + // Act + var result = await ServiceWithConfig.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeTrue($"Expected bundling to succeed, but got errors: {string.Join("; ", Collector.Diagnostics.Select(d => d.Message))}"); + Collector.Errors.Should().Be(0); + + var outputFiles = FileSystem.Directory.GetFiles(outputDir, "*.yaml"); + outputFiles.Should().NotBeEmpty("Expected an output file to be created"); + var bundleContent = await FileSystem.File.ReadAllTextAsync(outputFiles[0], TestContext.Current.CancellationToken); + + // Only repo is persisted in the bundle products array; owner is used for PR URL normalization at render time + bundleContent.Should().Contain("repo: cloud", "Profile repo should be written to bundle product entries"); + bundleContent.Should().NotContain("owner:", "owner is not stored in the bundle products array"); + } + + [Fact] + public async Task BundleChangelogs_WithProfile_NoRepoOwner_PreservesExistingFallbackBehavior() + { + // Arrange - when profile has no repo/owner, the bundle products have no repo field + // (existing fallback: product ID is used at render time if no repo is present) + + // language=yaml + var configContent = + """ + bundle: + profiles: + es-release: + products: "elasticsearch {version} {lifecycle}" + output: "elasticsearch-{version}.yaml" + """; + + var configPath = FileSystem.Path.Combine(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "changelog.yml"); + FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); + await FileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); + + // language=yaml + var changelog1 = + """ + title: Elasticsearch feature + type: feature + products: + - product: elasticsearch + target: 9.2.0 + lifecycle: ga + prs: + - https://github.com/elastic/elasticsearch/pull/100 + """; + + var file1 = FileSystem.Path.Combine(_changelogDir, "1755268130-feature.yaml"); + await FileSystem.File.WriteAllTextAsync(file1, changelog1, TestContext.Current.CancellationToken); + + var outputDir = FileSystem.Path.Combine(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + FileSystem.Directory.CreateDirectory(outputDir); + + var input = new BundleChangelogsArguments + { + Directory = _changelogDir, + Profile = "es-release", + ProfileArgument = "9.2.0", + Config = configPath, + OutputDirectory = outputDir + }; + + // Act + var result = await ServiceWithConfig.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); + + // Assert — succeeds without error; no repo field written to products + result.Should().BeTrue($"Expected bundling to succeed, but got errors: {string.Join("; ", Collector.Diagnostics.Select(d => d.Message))}"); + Collector.Errors.Should().Be(0); + + var outputFiles = FileSystem.Directory.GetFiles(outputDir, "*.yaml"); + outputFiles.Should().NotBeEmpty("Expected an output file to be created"); + var bundleContent = await FileSystem.File.ReadAllTextAsync(outputFiles[0], TestContext.Current.CancellationToken); + + bundleContent.Should().NotContain("repo:", "No repo field should be present when profile omits repo"); + bundleContent.Should().NotContain("owner:", "No owner field should be present when profile omits owner"); + } + + [Fact] + public async Task BundleChangelogs_WithProfileMode_MissingConfig_ReturnsErrorWithAdvice() + { + // Arrange - no config file exists at ./changelog.yml or ./docs/changelog.yml. + // Use a fresh MockFileSystem with a known CWD so discovery returns no results. + var cwdFs = new System.IO.Abstractions.TestingHelpers.MockFileSystem( + null, + currentDirectory: "/empty-project" + ); + cwdFs.Directory.CreateDirectory("/empty-project"); + var service = new ChangelogBundlingService(LoggerFactory, ConfigurationContext, cwdFs); + + var input = new BundleChangelogsArguments + { + Profile = "es-release", + ProfileArgument = "9.2.0" + // Config intentionally omitted — triggers CWD discovery + }; + + // Act + var result = await service.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeFalse("Should fail when no config file is found"); + Collector.Diagnostics.Should().ContainSingle(d => + d.Severity == Severity.Error && + (d.Message.Contains("changelog.yml") || d.Message.Contains("changelog init")), + "Error message should mention changelog.yml or advise running changelog init" + ); + } + + [Fact] + public async Task BundleChangelogs_WithProfileMode_ConfigAtCurrentDir_LoadsSuccessfully() + { + // Arrange - changelog.yml is at ./changelog.yml (in the current working directory) + var cwdFs = new System.IO.Abstractions.TestingHelpers.MockFileSystem( + null, + currentDirectory: "/test-root" + ); + cwdFs.Directory.CreateDirectory("/test-root"); + cwdFs.Directory.CreateDirectory("/test-root/changelogs"); + cwdFs.Directory.CreateDirectory("/test-root/output"); + + // language=yaml + var configContent = + """ + bundle: + directory: /test-root/changelogs + profiles: + es-release: + products: "elasticsearch {version} {lifecycle}" + output: "elasticsearch-{version}.yaml" + """; + await cwdFs.File.WriteAllTextAsync("/test-root/changelog.yml", configContent, TestContext.Current.CancellationToken); + + // language=yaml + var changelogContent = + """ + title: Elasticsearch feature + type: feature + products: + - product: elasticsearch + target: 9.2.0 + lifecycle: ga + prs: + - https://github.com/elastic/elasticsearch/pull/100 + """; + await cwdFs.File.WriteAllTextAsync("/test-root/changelogs/1755268130-feature.yaml", changelogContent, TestContext.Current.CancellationToken); + + var service = new ChangelogBundlingService(LoggerFactory, ConfigurationContext, cwdFs); + + var input = new BundleChangelogsArguments + { + Profile = "es-release", + ProfileArgument = "9.2.0", + OutputDirectory = "/test-root/output" + // Config intentionally omitted — should discover /test-root/changelog.yml + }; + + // Act + var result = await service.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeTrue($"Expected bundling to succeed. Errors: {string.Join("; ", Collector.Diagnostics.Where(d => d.Severity == Severity.Error).Select(d => d.Message))}"); + Collector.Errors.Should().Be(0); + cwdFs.Directory.GetFiles("/test-root/output", "*.yaml").Should().NotBeEmpty("Expected output file to be created"); + } + + [Fact] + public async Task BundleChangelogs_WithProfileMode_ConfigAtDocsSubdir_LoadsSuccessfully() + { + // Arrange - changelog.yml is at ./docs/changelog.yml (the second discovery candidate) + var cwdFs = new System.IO.Abstractions.TestingHelpers.MockFileSystem( + null, + currentDirectory: "/test-root" + ); + cwdFs.Directory.CreateDirectory("/test-root"); + cwdFs.Directory.CreateDirectory("/test-root/docs"); + cwdFs.Directory.CreateDirectory("/test-root/changelogs"); + cwdFs.Directory.CreateDirectory("/test-root/output"); + + // language=yaml + var configContent = + """ + bundle: + directory: /test-root/changelogs + profiles: + es-release: + products: "elasticsearch {version} {lifecycle}" + output: "elasticsearch-{version}.yaml" + """; + // Config is in docs/ subdir, not in CWD directly + await cwdFs.File.WriteAllTextAsync("/test-root/docs/changelog.yml", configContent, TestContext.Current.CancellationToken); + + // language=yaml + var changelogContent = + """ + title: Elasticsearch feature + type: feature + products: + - product: elasticsearch + target: 9.2.0 + lifecycle: ga + prs: + - https://github.com/elastic/elasticsearch/pull/100 + """; + await cwdFs.File.WriteAllTextAsync("/test-root/changelogs/1755268130-feature.yaml", changelogContent, TestContext.Current.CancellationToken); + + var service = new ChangelogBundlingService(LoggerFactory, ConfigurationContext, cwdFs); + + var input = new BundleChangelogsArguments + { + Profile = "es-release", + ProfileArgument = "9.2.0", + OutputDirectory = "/test-root/output" + // Config intentionally omitted — should discover /test-root/docs/changelog.yml + }; + + // Act + var result = await service.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeTrue($"Expected bundling to succeed. Errors: {string.Join("; ", Collector.Diagnostics.Where(d => d.Severity == Severity.Error).Select(d => d.Message))}"); + Collector.Errors.Should().Be(0); + cwdFs.Directory.GetFiles("/test-root/output", "*.yaml").Should().NotBeEmpty("Expected output file to be created"); + } + private static string ExtractChecksum(string bundleContent) { var lines = bundleContent.Split('\n'); diff --git a/tests/Elastic.Changelog.Tests/Changelogs/ChangelogRemoveTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/ChangelogRemoveTests.cs index dc7a7bb2d..00e2d07cb 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/ChangelogRemoveTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/ChangelogRemoveTests.cs @@ -606,6 +606,37 @@ public async Task Remove_WithProfile_MissingProfileArg_ReturnsError() d.Message.Contains("requires a version number")); } + [Fact] + public async Task Remove_WithProfileMode_MissingConfig_ReturnsErrorWithAdvice() + { + // Arrange - no config file exists at ./changelog.yml or ./docs/changelog.yml. + // Use a fresh MockFileSystem with a known CWD so discovery returns no results. + var cwdFs = new System.IO.Abstractions.TestingHelpers.MockFileSystem( + null, + currentDirectory: "/empty-project" + ); + cwdFs.Directory.CreateDirectory("/empty-project"); + var service = new ChangelogRemoveService(LoggerFactory, ConfigurationContext, cwdFs); + + var input = new ChangelogRemoveArguments + { + Profile = "es-release", + ProfileArgument = "9.2.0" + // Config intentionally omitted — triggers CWD discovery + }; + + // Act + var result = await service.RemoveChangelogs(Collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeFalse("Should fail when no config file is found"); + Collector.Diagnostics.Should().ContainSingle(d => + d.Severity == Severity.Error && + (d.Message.Contains("changelog.yml") || d.Message.Contains("changelog init")), + "Error message should mention changelog.yml or advise running changelog init" + ); + } + [Fact] public async Task Remove_WithProfile_NoProductsAndVersionArg_ReturnsSpecificError() { From b646df60dfb694cec9d4d2f96d14c1b7e1f6703d Mon Sep 17 00:00:00 2001 From: Lisa Cawley Date: Wed, 25 Feb 2026 16:38:14 -0800 Subject: [PATCH 4/9] Apply suggestion from @lcawl --- docs/contribute/changelog.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/contribute/changelog.md b/docs/contribute/changelog.md index 1a8bae548..d982dd319 100644 --- a/docs/contribute/changelog.md +++ b/docs/contribute/changelog.md @@ -547,7 +547,7 @@ bundle: elasticsearch-release: products: "elasticsearch {version} {lifecycle}" output: "bundles/elasticsearch-{version}.yaml" - output_products: "elasticsearch {version} ga" + output_products: "elasticsearch {version}" repo: elasticsearch owner: elastic hide_features: From 45dbf85355e8e3ff5d07bf928c40fd7b62f0bc77 Mon Sep 17 00:00:00 2001 From: lcawl Date: Wed, 25 Feb 2026 18:04:49 -0800 Subject: [PATCH 5/9] Add monthly bundle example --- docs/cli/release/changelog-bundle.md | 29 ++++++++++++---------------- 1 file changed, 12 insertions(+), 17 deletions(-) diff --git a/docs/cli/release/changelog-bundle.md b/docs/cli/release/changelog-bundle.md index 3a46fa8f3..b328d47f1 100644 --- a/docs/cli/release/changelog-bundle.md +++ b/docs/cli/release/changelog-bundle.md @@ -213,18 +213,11 @@ bundle: products: "elasticsearch {version} ga" <1> output: "elasticsearch-{version}.yaml" - # Find changelogs with any lifecycle (wildcard) - serverless-release: - products: "cloud-serverless {version} *" <2> - output_products: "cloud-serverless {version}" + # Find changelogs with any lifecycle and a partial date + serverless-monthly: + products: "cloud-serverless {version}-* *" <2> output: "serverless-{version}.yaml" - repo: elasticsearch - owner: elastic - - # Find changelogs that match a promotion report - serverless-release-report: output_products: "cloud-serverless {version}" - output: "serverless-{version}.yaml" repo: elasticsearch owner: elastic @@ -239,7 +232,7 @@ bundle: ``` 1. Bundles any changelogs that have `product: elasticsearch`, `lifecycle: ga`, and the version specified in the command. This is equivalent to the `--input-products` command option. -2. Bundles any changelogs that have `product: cloud-serverless`, any lifecycle, and the version specified in the command. This is equivalent to the `--input-products` command option's support for wildcards. +2. Bundles any changelogs that have `product: cloud-serverless`, any lifecycle, and the date partially specified in the command. This is equivalent to the `--input-products` command option's support for wildcards. 3. Adds a `hide-features` array in the bundle. This is equivalent to the `--hide-features` command option. 4. In this case, the lifecycle is inferred from the version. @@ -257,12 +250,14 @@ You can invoke those profiles with commands like this: # Bundle changelogs that match a specific version or date docs-builder changelog bundle elasticsearch-release 9.2.0 -# Bundle changelogs with wildcards -docs-builder changelog bundle serverless-releases 2026-02-* - -# Bundle changelogs that match a list of PRs in a promotion report -docs-builder changelog bundle serverless-release-report 2026-02 https://buildkite.../promotion-report.html +# Bundle changelogs with partial dates +docs-builder changelog bundle serverless-monthly 2026-02 +``` + From b5b38975e9da5317a97ead85502ba9aad0475b63 Mon Sep 17 00:00:00 2001 From: lcawl Date: Wed, 25 Feb 2026 18:26:37 -0800 Subject: [PATCH 6/9] Add bundle-level repo and owner defaults Adds repo and owner as top-level fields under bundle: in changelog.yml, providing a default that applies to all profiles. Profile-level values override the bundle-level default when set; otherwise the bundle-level value is used. This avoids repeating the same repo/owner in every profile when all profiles share the same repository. Also adds two tests verifying the fallback and override behaviour. Made-with: Cursor --- config/changelog.example.yml | 22 +-- .../Changelog/BundleConfiguration.cs | 11 ++ .../Bundling/ChangelogBundlingService.cs | 5 +- .../ChangelogConfigurationLoader.cs | 2 + .../ChangelogConfigurationYaml.cs | 10 ++ .../Changelogs/BundleChangelogsTests.cs | 127 ++++++++++++++++++ 6 files changed, 166 insertions(+), 11 deletions(-) diff --git a/config/changelog.example.yml b/config/changelog.example.yml index 519c25a40..06a151f07 100644 --- a/config/changelog.example.yml +++ b/config/changelog.example.yml @@ -169,13 +169,20 @@ bundle: output_directory: docs/releases # Whether to resolve (copy contents) by default resolve: true + # Optional: default GitHub repo name applied to all profiles that do not specify their own. + # Used by the {changelog} directive to generate correct PR/issue links when the product ID + # differs from the GitHub repository name. Can be overridden per profile. + # repo: elasticsearch + # Optional: default GitHub owner applied to all profiles that do not specify their own. + # owner: elastic # Named bundle profiles for different release scenarios. # Profiles can be used with both 'changelog bundle' and 'changelog remove': # 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' and 'hide_features' fields are bundle-specific and are ignored for removal. + # The 'output', 'output_products', 'repo', 'owner', and 'hide_features' fields are + # bundle-specific and are ignored for removal. profiles: # Example: Elasticsearch release profile # elasticsearch-release: @@ -186,21 +193,18 @@ bundle: # # Optional: override the products array written to the bundle output. # # Useful when the bundle should advertise a different lifecycle than was used for filtering. # # output_products: "elasticsearch {version} ga" - # # Optional: GitHub repo name written to each product entry in the bundle. - # # Used by the {changelog} directive to generate correct PR/issue links. - # # Only needed when the product ID does not match the GitHub repository name. + # # Optional: profile-specific GitHub repo name (overrides bundle.repo if set). + # # Only needed when this profile's product ID differs from the repository name. # # repo: elasticsearch - # # Optional: GitHub owner written to each product entry in the bundle (default: elastic). + # # Optional: profile-specific GitHub owner (overrides bundle.owner if set). # # owner: elastic - # Example: Serverless release profile + # Example: Serverless release profile (product ID "cloud-serverless" differs from repo "cloud") # serverless-release: # products: "cloud-serverless {version} *" # output: "serverless-{version}.yaml" - # # repo and owner are required here because the product ID "cloud-serverless" differs - # # from the GitHub repository name "cloud". + # # repo overrides the bundle-level default for this profile only # # repo: cloud - # # owner: elastic # # Feature IDs to hide when bundling with this profile (accepts string or list) # hide_features: # - feature-flag-1 diff --git a/src/Elastic.Documentation.Configuration/Changelog/BundleConfiguration.cs b/src/Elastic.Documentation.Configuration/Changelog/BundleConfiguration.cs index 544f83f1a..760d190f4 100644 --- a/src/Elastic.Documentation.Configuration/Changelog/BundleConfiguration.cs +++ b/src/Elastic.Documentation.Configuration/Changelog/BundleConfiguration.cs @@ -27,6 +27,17 @@ public record BundleConfiguration /// public bool Resolve { get; init; } = true; + /// + /// Default GitHub repository name applied to all profiles that do not specify their own. + /// Used for generating correct PR/issue links when the product ID differs from the repo name. + /// + public string? Repo { get; init; } + + /// + /// Default GitHub repository owner applied to all profiles that do not specify their own. + /// + public string? Owner { get; init; } + /// /// Named bundle profiles for different release scenarios. /// diff --git a/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs b/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs index 8ec123e71..2e7248dbb 100644 --- a/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs +++ b/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs @@ -266,8 +266,9 @@ public async Task BundleChangelogs(IDiagnosticsCollector collector, Bundle outputProducts = ProfileFilterResolver.ParseProfileProducts(outputProductsPattern); } - repo = profile.Repo; - owner = profile.Owner; + // Profile-level repo/owner takes precedence; fall back to bundle-level defaults + repo = profile.Repo ?? config?.Bundle?.Repo; + owner = profile.Owner ?? config?.Bundle?.Owner; mergedHideFeatures = profile.HideFeatures?.Count > 0 ? [.. profile.HideFeatures] : null; } diff --git a/src/services/Elastic.Changelog/Configuration/ChangelogConfigurationLoader.cs b/src/services/Elastic.Changelog/Configuration/ChangelogConfigurationLoader.cs index 9ed6401ff..69487afcd 100644 --- a/src/services/Elastic.Changelog/Configuration/ChangelogConfigurationLoader.cs +++ b/src/services/Elastic.Changelog/Configuration/ChangelogConfigurationLoader.cs @@ -422,6 +422,8 @@ private static BundleConfiguration ParseBundleConfiguration(BundleConfigurationY Directory = yaml.Directory, OutputDirectory = yaml.OutputDirectory, Resolve = yaml.Resolve ?? true, + Repo = yaml.Repo, + Owner = yaml.Owner, Profiles = profiles }; } diff --git a/src/services/Elastic.Changelog/Serialization/ChangelogConfigurationYaml.cs b/src/services/Elastic.Changelog/Serialization/ChangelogConfigurationYaml.cs index 3d0da628e..8688e402d 100644 --- a/src/services/Elastic.Changelog/Serialization/ChangelogConfigurationYaml.cs +++ b/src/services/Elastic.Changelog/Serialization/ChangelogConfigurationYaml.cs @@ -212,6 +212,16 @@ internal record BundleConfigurationYaml /// public bool? Resolve { get; set; } + /// + /// Default GitHub repository name applied to all profiles that do not specify their own. + /// + public string? Repo { get; set; } + + /// + /// Default GitHub repository owner applied to all profiles that do not specify their own. + /// + public string? Owner { get; set; } + /// /// Named bundle profiles. /// diff --git a/tests/Elastic.Changelog.Tests/Changelogs/BundleChangelogsTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/BundleChangelogsTests.cs index 4c839138b..8fe3b06b7 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/BundleChangelogsTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/BundleChangelogsTests.cs @@ -2864,6 +2864,133 @@ public async Task BundleChangelogs_WithProfile_RepoAndOwner_WritesValuesToProduc bundleContent.Should().NotContain("owner:", "owner is not stored in the bundle products array"); } + [Fact] + public async Task BundleChangelogs_WithProfile_BundleLevelRepo_AppliesWhenProfileOmitsRepo() + { + // Arrange - repo is set at bundle level, not in the profile; profile should inherit it + + // language=yaml + var configContent = + """ + bundle: + repo: elasticsearch + owner: elastic + profiles: + es-release: + products: "elasticsearch {version} {lifecycle}" + output: "elasticsearch-{version}.yaml" + """; + + var configPath = FileSystem.Path.Combine(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "changelog.yml"); + FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); + await FileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); + + // language=yaml + var changelog1 = + """ + title: Elasticsearch feature + type: feature + products: + - product: elasticsearch + target: 9.2.0 + lifecycle: ga + prs: + - https://github.com/elastic/elasticsearch/pull/100 + """; + + var file1 = FileSystem.Path.Combine(_changelogDir, "1755268130-feature.yaml"); + await FileSystem.File.WriteAllTextAsync(file1, changelog1, TestContext.Current.CancellationToken); + + var outputDir = FileSystem.Path.Combine(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + FileSystem.Directory.CreateDirectory(outputDir); + + var input = new BundleChangelogsArguments + { + Directory = _changelogDir, + Profile = "es-release", + ProfileArgument = "9.2.0", + Config = configPath, + OutputDirectory = outputDir + }; + + // Act + var result = await ServiceWithConfig.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeTrue($"Expected bundling to succeed, but got errors: {string.Join("; ", Collector.Diagnostics.Select(d => d.Message))}"); + Collector.Errors.Should().Be(0); + + var outputFiles = FileSystem.Directory.GetFiles(outputDir, "*.yaml"); + outputFiles.Should().NotBeEmpty(); + var bundleContent = await FileSystem.File.ReadAllTextAsync(outputFiles[0], TestContext.Current.CancellationToken); + + bundleContent.Should().Contain("repo: elasticsearch", "bundle-level repo should be applied when profile omits repo"); + } + + [Fact] + public async Task BundleChangelogs_WithProfile_ProfileRepoOverridesBundleRepo() + { + // Arrange - both bundle-level and profile-level repo are set; profile-level wins + + // language=yaml + var configContent = + """ + bundle: + repo: wrong-repo + profiles: + es-release: + products: "elasticsearch {version} {lifecycle}" + output: "elasticsearch-{version}.yaml" + repo: elasticsearch + """; + + var configPath = FileSystem.Path.Combine(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "changelog.yml"); + FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); + await FileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); + + // language=yaml + var changelog1 = + """ + title: Elasticsearch feature + type: feature + products: + - product: elasticsearch + target: 9.2.0 + lifecycle: ga + prs: + - https://github.com/elastic/elasticsearch/pull/100 + """; + + var file1 = FileSystem.Path.Combine(_changelogDir, "1755268130-feature.yaml"); + await FileSystem.File.WriteAllTextAsync(file1, changelog1, TestContext.Current.CancellationToken); + + var outputDir = FileSystem.Path.Combine(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + FileSystem.Directory.CreateDirectory(outputDir); + + var input = new BundleChangelogsArguments + { + Directory = _changelogDir, + Profile = "es-release", + ProfileArgument = "9.2.0", + Config = configPath, + OutputDirectory = outputDir + }; + + // Act + var result = await ServiceWithConfig.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeTrue($"Expected bundling to succeed, but got errors: {string.Join("; ", Collector.Diagnostics.Select(d => d.Message))}"); + Collector.Errors.Should().Be(0); + + var outputFiles = FileSystem.Directory.GetFiles(outputDir, "*.yaml"); + outputFiles.Should().NotBeEmpty(); + var bundleContent = await FileSystem.File.ReadAllTextAsync(outputFiles[0], TestContext.Current.CancellationToken); + + bundleContent.Should().Contain("repo: elasticsearch", "profile-level repo should override bundle-level repo"); + bundleContent.Should().NotContain("repo: wrong-repo", "bundle-level repo should be overridden by profile-level repo"); + } + [Fact] public async Task BundleChangelogs_WithProfile_NoRepoOwner_PreservesExistingFallbackBehavior() { From 1cbe596a3957e549e9bafd963b85f86d9ed10cf8 Mon Sep 17 00:00:00 2001 From: lcawl Date: Wed, 25 Feb 2026 18:28:24 -0800 Subject: [PATCH 7/9] Apply bundle.repo and bundle.owner defaults in option-based mode Extends ApplyConfigDefaults in both ChangelogBundlingService and ChangelogRemoveService to fall back to bundle.repo and bundle.owner from config when --repo/--owner are not supplied on the CLI. This mirrors the existing behaviour for profile-based commands and means repo and owner rarely need to be specified on the command line when a changelog.yml with bundle-level defaults is present. Precedence: explicit CLI flag > bundle.repo/owner config > nothing (renderer falls back to product ID at render time). Adds four tests covering the config fallback and CLI-override paths for the bundle command. Made-with: Cursor --- .../Bundling/ChangelogBundlingService.cs | 8 +- .../Bundling/ChangelogRemoveService.cs | 7 +- .../Changelogs/BundleChangelogsTests.cs | 112 ++++++++++++++++++ 3 files changed, 125 insertions(+), 2 deletions(-) diff --git a/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs b/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs index 2e7248dbb..48c9dcb6f 100644 --- a/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs +++ b/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs @@ -301,11 +301,17 @@ private static BundleChangelogsArguments ApplyConfigDefaults(BundleChangelogsArg // Apply resolve: CLI takes precedence over config. Only use config when CLI did not specify. var resolve = input.Resolve ?? config.Bundle.Resolve; + // Apply repo/owner: CLI takes precedence; fall back to bundle-level config defaults. + var repo = input.Repo ?? config.Bundle.Repo; + var owner = input.Owner ?? config.Bundle.Owner; + return input with { Directory = directory, Output = output, - Resolve = resolve + Resolve = resolve, + Repo = repo, + Owner = owner }; } diff --git a/src/services/Elastic.Changelog/Bundling/ChangelogRemoveService.cs b/src/services/Elastic.Changelog/Bundling/ChangelogRemoveService.cs index a72915eb7..040492885 100644 --- a/src/services/Elastic.Changelog/Bundling/ChangelogRemoveService.cs +++ b/src/services/Elastic.Changelog/Bundling/ChangelogRemoveService.cs @@ -215,7 +215,12 @@ public async Task RemoveChangelogs(IDiagnosticsCollector collector, Change private ChangelogRemoveArguments ApplyConfigDefaults(ChangelogRemoveArguments input, ChangelogConfiguration? config) { var directory = input.Directory ?? config?.Bundle?.Directory ?? _fileSystem.Directory.GetCurrentDirectory(); - return input with { Directory = directory }; + + // Apply repo/owner: CLI takes precedence; fall back to bundle-level config defaults. + var repo = input.Repo ?? config?.Bundle?.Repo; + var owner = input.Owner ?? config?.Bundle?.Owner; + + return input with { Directory = directory, Repo = repo, Owner = owner }; } private bool ValidateInput(IDiagnosticsCollector collector, ChangelogRemoveArguments input) diff --git a/tests/Elastic.Changelog.Tests/Changelogs/BundleChangelogsTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/BundleChangelogsTests.cs index 8fe3b06b7..e0f1e9498 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/BundleChangelogsTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/BundleChangelogsTests.cs @@ -1972,6 +1972,118 @@ public async Task BundleChangelogs_WithoutRepoOption_OmitsRepoFieldInOutput() bundleContent.Should().NotContain("repo:"); } + [Fact] + public async Task BundleChangelogs_WithBundleLevelRepoConfig_UsesConfigRepoWhenOptionNotSpecified() + { + // Arrange - bundle.repo in config is used when --repo is not provided on the CLI + + // language=yaml + var configContent = + """ + bundle: + repo: cloud + owner: elastic + """; + + var configPath = FileSystem.Path.Combine(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "changelog.yml"); + FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); + await FileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); + + // language=yaml + var changelog1 = + """ + title: Serverless feature + type: feature + products: + - product: cloud-serverless + target: 2025-06-01 + lifecycle: ga + prs: + - https://github.com/elastic/cloud/pull/100 + """; + + var file1 = FileSystem.Path.Combine(_changelogDir, "1755268130-serverless-feature.yaml"); + await FileSystem.File.WriteAllTextAsync(file1, changelog1, TestContext.Current.CancellationToken); + + var outputPath = FileSystem.Path.Combine(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "bundle.yaml"); + FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(outputPath)!); + + var input = new BundleChangelogsArguments + { + Directory = _changelogDir, + All = true, + Config = configPath, + Output = outputPath + // No --repo or --owner: should be picked up from bundle config + }; + + // Act + var result = await ServiceWithConfig.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeTrue($"Expected bundling to succeed, but got errors: {string.Join("; ", Collector.Diagnostics.Select(d => d.Message))}"); + Collector.Errors.Should().Be(0); + + var bundleContent = await FileSystem.File.ReadAllTextAsync(outputPath, TestContext.Current.CancellationToken); + bundleContent.Should().Contain("repo: cloud", "bundle.repo config should be applied when --repo is not specified"); + } + + [Fact] + public async Task BundleChangelogs_WithRepoOptionAndBundleLevelConfig_CliOptionTakesPrecedence() + { + // Arrange - explicit --repo overrides bundle.repo in config + + // language=yaml + var configContent = + """ + bundle: + repo: wrong-repo + """; + + var configPath = FileSystem.Path.Combine(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "changelog.yml"); + FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); + await FileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); + + // language=yaml + var changelog1 = + """ + title: Serverless feature + type: feature + products: + - product: cloud-serverless + target: 2025-06-01 + lifecycle: ga + prs: + - https://github.com/elastic/cloud/pull/100 + """; + + var file1 = FileSystem.Path.Combine(_changelogDir, "1755268130-serverless-feature.yaml"); + await FileSystem.File.WriteAllTextAsync(file1, changelog1, TestContext.Current.CancellationToken); + + var outputPath = FileSystem.Path.Combine(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "bundle.yaml"); + FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(outputPath)!); + + var input = new BundleChangelogsArguments + { + Directory = _changelogDir, + All = true, + Config = configPath, + Output = outputPath, + Repo = "cloud" // explicit CLI --repo should win over bundle.repo: wrong-repo + }; + + // Act + var result = await ServiceWithConfig.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeTrue($"Expected bundling to succeed, but got errors: {string.Join("; ", Collector.Diagnostics.Select(d => d.Message))}"); + Collector.Errors.Should().Be(0); + + var bundleContent = await FileSystem.File.ReadAllTextAsync(outputPath, TestContext.Current.CancellationToken); + bundleContent.Should().Contain("repo: cloud", "explicit --repo should override bundle.repo config"); + bundleContent.Should().NotContain("repo: wrong-repo"); + } + [Fact] public async Task BundleChangelogs_WithOutputProductsAndRepo_IncludesRepoInAllProducts() { From bdc28210f46b52f737e0f4074acb33be7c2ad13e Mon Sep 17 00:00:00 2001 From: lcawl Date: Wed, 25 Feb 2026 18:33:17 -0800 Subject: [PATCH 8/9] Document bundle.repo/owner defaults in help text and docs Updates all three places that describe --repo/--owner behaviour: - CLI param XML docs (bundle and remove): clarify both are optional and fall back to bundle.repo/bundle.owner in changelog.yml - changelog-bundle.md: expand --repo/--owner option descriptions; rewrite "Repository name in bundles" section to show the three-level precedence (CLI > profile > bundle config) with YAML examples; update profile fields table and the examples section to demonstrate bundle-level defaults with per-profile overrides - changelog.md: add a note that --repo/--owner fall back to config in option-based bundling; expand profile fields table to include bundle-level repo/owner defaults; update the --prs and --issues callout text to mention the config fallback Made-with: Cursor --- docs/cli/release/changelog-bundle.md | 78 +++++++++++++------ docs/contribute/changelog.md | 33 ++++++-- .../docs-builder/Commands/ChangelogCommand.cs | 8 +- 3 files changed, 82 insertions(+), 37 deletions(-) diff --git a/docs/cli/release/changelog-bundle.md b/docs/cli/release/changelog-bundle.md index b328d47f1..82bc778a3 100644 --- a/docs/cli/release/changelog-bundle.md +++ b/docs/cli/release/changelog-bundle.md @@ -98,7 +98,8 @@ Using any of them with a profile returns an error. : This value replaces information that would otherwise by derived from changelogs. `--owner ` -: The GitHub repository owner, which is required when pull requests or issues are specified as numbers. +: Optional: The GitHub repository owner, required when pull requests or issues are specified as numbers. +: Falls back to `bundle.owner` in `changelog.yml` when not specified. `--prs ` : Filter by pull request URLs or numbers (comma-separated), or a path to a newline-delimited file containing PR URLs or numbers. Can be specified multiple times. @@ -108,7 +109,9 @@ Using any of them with a profile returns an error. : When specifying a file path, provide a single value that points to a newline-delimited file. `--repo ` -: The GitHub repository name, which is required when pull requests or issues are specified as numbers. +: Optional: The GitHub repository name, required when pull requests or issues are specified as numbers. +: Also sets the `repo` field in each bundle product entry for correct PR/issue link generation. +: Falls back to `bundle.repo` in `changelog.yml` when not specified; if that is also absent, the product ID is used. `--resolve` : Optional: Copy the contents of each changelog file into the entries array. @@ -142,19 +145,39 @@ If you specify a file path with a different extension (not `.yml` or `.yaml`), t ## Repository name in bundles [changelog-bundle-repo] -When you specify the `--repo` option (option-based mode) or the `repo` field in a profile (profile-based mode), the repository name is stored in the bundle's product metadata. -This ensures that PR and issue links are generated correctly when the bundle is rendered. +The repository name is stored in each bundle product entry to ensure that PR and issue links are generated correctly when the bundle is rendered. +It can be set in three ways, in order of precedence: -```sh -docs-builder changelog bundle \ - --input-products "cloud-serverless 2025-12-02 *" \ - --repo cloud \ <1> - --output /path/to/bundles/2025-12-02.yaml +1. **`--repo` option** (option-based mode only) +2. **`repo` field in the profile** (profile-based mode only; overrides the bundle-level default) +3. **`bundle.repo` in `changelog.yml`** (applies to both modes as a default when neither of the above is set) + +Setting `bundle.repo` and `bundle.owner` in your configuration means you rarely need to pass `--repo` and `--owner` on the command line: + +```yaml +bundle: + repo: cloud + owner: elastic ``` -1. The GitHub repository name. This is stored in each product entry in the bundle. +You can still override them per profile if a project has multiple products with different repos: + +```yaml +bundle: + repo: cloud # default for all profiles + owner: elastic + profiles: + elasticsearch-release: + products: "elasticsearch {version} {lifecycle}" + output: "elasticsearch-{version}.yaml" + repo: elasticsearch # overrides bundle.repo for this profile only + serverless-release: + products: "cloud-serverless {version} *" + output: "serverless-{version}.yaml" + # inherits repo: cloud from bundle level +``` -The bundle output will include a `repo` field in each product: +The bundle output includes a `repo` field in each product: ```yaml products: @@ -167,10 +190,10 @@ entries: checksum: 6c3243f56279b1797b5dfff6c02ebf90b9658464 ``` -When rendering, pull request and issue links will use `https://github.com/elastic/cloud/...` instead of the product ID in the URL. +When rendering, pull request and issue links use `https://github.com/elastic/cloud/...` instead of the product ID. :::{note} -If the `repo` field is not specified, the product ID is used as a fallback for link generation. +If no `repo` is set at any level, the product ID is used as a fallback for link generation. This may result in broken links if the product ID doesn't match the GitHub repository name (for example, `cloud-serverless` vs `cloud`). ::: @@ -191,11 +214,11 @@ Bundle profiles in `changelog.yml` support the following fields: : Example: `"elasticsearch {version} ga"` `repo` -: Optional. The GitHub repository name written to each product entry in the bundle. Used by the `{changelog}` directive to generate correct PR/issue links. Only needed when the product ID doesn't match the GitHub repository name. +: Optional. The GitHub repository name written to each product entry in the bundle. Used by the `{changelog}` directive to generate correct PR/issue links. Only needed when the product ID doesn't match the GitHub repository name. Overrides `bundle.repo` when set. : Example: `repo: cloud` (for the `cloud-serverless` product) `owner` -: Optional. The GitHub owner written to each product entry in the bundle. Defaults to `elastic` when not specified. +: Optional. The GitHub owner written to each product entry in the bundle. Overrides `bundle.owner` when set. : Example: `owner: elastic` `hide_features` @@ -207,34 +230,39 @@ The following changelog configuration example contains multiple profiles for fil ```yaml bundle: + repo: cloud <1> + owner: elastic profiles: # Find changelogs with a specific lifecycle elasticsearch-ga-only: - products: "elasticsearch {version} ga" <1> + products: "elasticsearch {version} ga" <2> output: "elasticsearch-{version}.yaml" + repo: elasticsearch <3> # Find changelogs with any lifecycle and a partial date serverless-monthly: - products: "cloud-serverless {version}-* *" <2> + products: "cloud-serverless {version}-* *" <4> output: "serverless-{version}.yaml" output_products: "cloud-serverless {version}" - repo: elasticsearch - owner: elastic + # repo and owner inherited from bundle level # Infer the lifecycle from the version elasticsearch-release: - hide_features: <3> + hide_features: <5> - feature-flag-1 - feature-flag-2 - products: "elasticsearch {version} {lifecycle}" <4> + products: "elasticsearch {version} {lifecycle}" <6> output: "elasticsearch-{version}.yaml" output_products: "elasticsearch {version}" + repo: elasticsearch <3> ``` -1. Bundles any changelogs that have `product: elasticsearch`, `lifecycle: ga`, and the version specified in the command. This is equivalent to the `--input-products` command option. -2. Bundles any changelogs that have `product: cloud-serverless`, any lifecycle, and the date partially specified in the command. This is equivalent to the `--input-products` command option's support for wildcards. -3. Adds a `hide-features` array in the bundle. This is equivalent to the `--hide-features` command option. -4. In this case, the lifecycle is inferred from the version. +1. Bundle-level defaults that apply to all profiles. Individual profiles can override these. +2. Bundles any changelogs that have `product: elasticsearch`, `lifecycle: ga`, and the version specified in the command. This is equivalent to the `--input-products` command option. +3. Overrides the bundle-level `repo: cloud` for this profile because the `elasticsearch` product matches its GitHub repository name. +4. Bundles any changelogs that have `product: cloud-serverless`, any lifecycle, and the date partially specified in the command. This is equivalent to the `--input-products` command option's support for wildcards. +5. Adds a `hide-features` array in the bundle. This is equivalent to the `--hide-features` command option. +6. In this case, the lifecycle is inferred from the version. For example, when the version is: diff --git a/docs/contribute/changelog.md b/docs/contribute/changelog.md index cb0cf7532..7d3f4b434 100644 --- a/docs/contribute/changelog.md +++ b/docs/contribute/changelog.md @@ -513,6 +513,7 @@ If you likewise want to regenerate your [Asciidoc or Markdown files](#render-cha When you do not specify `--directory`, the command reads changelog files from `bundle.directory` in your changelog configuration if it is set, otherwise from the current directory. When you do not specify `--output`, the command writes the bundle to `bundle.output_directory` from your changelog configuration (creating `changelog-bundle.yaml` in that directory) if it is set, otherwise to `changelog-bundle.yaml` in the input directory. +When you do not specify `--repo` or `--owner`, the command falls back to `bundle.repo` and `bundle.owner` in the changelog configuration, so you rarely need to pass these on the command line. ### Profile-based bundling [changelog-bundle-profile] @@ -532,30 +533,46 @@ docs-builder changelog bundle elasticsearch-release ./promotion-report.html The command automatically discovers `changelog.yml` by checking `./changelog.yml` then `./docs/changelog.yml` relative to your current directory. If no configuration file is found, the command returns an error with advice to create one (using `docs-builder changelog init`) or to run from the directory where the file exists. -Profile configuration fields in the `bundle.profiles` section: +You can set `bundle.repo` and `bundle.owner` directly under `bundle:` as defaults that apply to all profiles. +Individual profiles can override them when needed. + +Top-level `bundle` fields: + +| Field | Description | +|---|---| +| `repo` | Default GitHub repository name applied to all profiles. Falls back to product ID if not set at any level. | +| `owner` | Default GitHub repository owner applied to all profiles. | + +Profile configuration fields in `bundle.profiles`: | Field | Description | |---|---| | `products` | Product filter pattern with `{version}` and `{lifecycle}` placeholders. Used to match changelog files. | | `output` | Output file path pattern with `{version}` and `{lifecycle}` placeholders. | | `output_products` | Optional override for the products array written to the bundle. Useful when the bundle should advertise a different lifecycle or version than the filter. | -| `repo` | GitHub repository name written to each product entry. Used to generate correct PR and issue links when the product ID differs from the repository name. | -| `owner` | GitHub repository owner. Used for normalizing short PR and issue references. | +| `repo` | Optional. Overrides `bundle.repo` for this profile only. | +| `owner` | Optional. Overrides `bundle.owner` for this profile only. | | `hide_features` | List of feature IDs to embed in the bundle as hidden. | Example profile configuration: ```yaml bundle: + repo: cloud # default for all profiles + owner: elastic profiles: elasticsearch-release: products: "elasticsearch {version} {lifecycle}" output: "bundles/elasticsearch-{version}.yaml" output_products: "elasticsearch {version}" - repo: elasticsearch - owner: elastic + repo: elasticsearch # overrides bundle.repo for this profile hide_features: - feature:experimental-api + serverless-release: + products: "cloud-serverless {version} *" + output: "bundles/serverless-{version}.yaml" + output_products: "cloud-serverless {version}" + # inherits repo: cloud and owner: elastic from bundle level ``` ### Filter by product [changelog-bundle-product] @@ -627,8 +644,8 @@ docs-builder changelog bundle --prs "108875,135873,136886" \ <1> 1. The comma-separated list of pull request numbers to seek. -2. The repository in the pull request URLs. This option is not required if you specify the short or full PR URLs in the `--prs` option. -3. The owner in the pull request URLs. This option is not required if you specify the short or full PR URLs in the `--prs` option. +2. The repository in the pull request URLs. Not required when using full PR URLs, or when `bundle.repo` is set in the changelog configuration. +3. The owner in the pull request URLs. Not required when using full PR URLs, or when `bundle.owner` is set in the changelog configuration. 4. The product metadata for the bundle. If it is not provided, it will be derived from all the changelog product values. If you have changelog files that reference those pull requests, the command creates a file like this: @@ -654,7 +671,7 @@ entries: You can use the `--issues` option to create a bundle of changelogs that relate to those GitHub issues. Provide either a comma-separated list of issues (`--issues "https://github.com/owner/repo/issues/123,456"`) or a path to a newline-delimited file (`--issues /path/to/file.txt`). -Issues can be identified by a full URL (such as `https://github.com/owner/repo/issues/123`), a short format (such as `owner/repo#123`), or just a number (in which case you must also provide `--owner` and `--repo` options). +Issues can be identified by a full URL (such as `https://github.com/owner/repo/issues/123`), a short format (such as `owner/repo#123`), or just a number (in which case `--owner` and `--repo` are required — or set via `bundle.owner` and `bundle.repo` in the configuration). ```sh docs-builder changelog bundle --issues "12345,12346" \ diff --git a/src/tooling/docs-builder/Commands/ChangelogCommand.cs b/src/tooling/docs-builder/Commands/ChangelogCommand.cs index 2d90fdd26..4f1a63fbb 100644 --- a/src/tooling/docs-builder/Commands/ChangelogCommand.cs +++ b/src/tooling/docs-builder/Commands/ChangelogCommand.cs @@ -403,9 +403,9 @@ async static (s, collector, state, ctx) => await s.CreateChangelog(collector, st /// Filter by issue URLs or numbers (comma-separated), or a path to a newline-delimited file containing issue URLs or numbers. Can be specified multiple times. Each occurrence can be either comma-separated issues (e.g., `--issues "https://github.com/owner/repo/issues/123,456"`) or a file path (e.g., `--issues /path/to/file.txt`). When specifying issues directly, provide comma-separated values. When specifying a file path, provide a single value that points to a newline-delimited file. Only one filter option can be specified: `--all`, `--input-products`, `--prs`, or `--issues`. /// Optional: Output path for the bundled changelog. Can be either (1) a directory path, in which case 'changelog-bundle.yaml' is created in that directory, or (2) a file path ending in .yml or .yaml. Uses config bundle.output_directory or defaults to 'changelog-bundle.yaml' in the input directory /// Optional: Explicitly set the products array in the output file in format "product target lifecycle, ...". Overrides any values from changelogs. - /// GitHub repository owner (required when PRs or issues are specified as numbers) + /// Optional: GitHub repository owner. Required when PRs or issues are specified as numbers. Falls back to bundle.owner in changelog.yml when not specified. /// Filter by pull request URLs or numbers (comma-separated), or a path to a newline-delimited file containing PR URLs or numbers. Can be specified multiple times. Only one filter option can be specified: `--all`, `--input-products`, `--prs`, or `--issues`. - /// GitHub repository name. Used for PR or issue filtering when PRs or issues are specified as numbers, and also sets the repo field in the bundle output for generating correct PR/issue links. If not specified, the product ID is used as the repo name in links. + /// Optional: GitHub repository name. Required when PRs or issues are specified as numbers. Also sets the repo field in bundle product entries for correct PR/issue link generation. Falls back to bundle.repo in changelog.yml when not specified; if that is also absent, the product ID is used. /// Optional: Copy the contents of each changelog file into the entries array. Uses config bundle.resolve or defaults to false. /// Optional: Explicitly turn off resolve (overrides config). /// @@ -698,10 +698,10 @@ async static (s, collector, state, ctx) => await s.BundleChangelogs(collector, s /// Print the files that would be removed without deleting them. Valid in both profile and raw mode. /// Proceed with removal even when files are referenced by unresolved bundles. Emits warnings instead of errors for each dependency. Valid in both profile and raw mode. /// Filter by issue URLs or numbers (comma-separated), or a path to a newline-delimited file containing issue URLs or numbers. Can be specified multiple times. Exactly one filter option must be specified: --all, --products, --prs, or --issues. - /// GitHub repository owner (required when PRs or issues are specified as numbers) + /// Optional: GitHub repository owner. Required when PRs or issues are specified as numbers. Falls back to bundle.owner in changelog.yml when not specified. /// Filter by products in format "product target lifecycle, ..." (e.g., "elasticsearch 9.3.0 ga"). All three parts are required but can be wildcards (*). Exactly one filter option must be specified: --all, --products, --prs, or --issues. /// Filter by pull request URLs or numbers (comma-separated), or a path to a newline-delimited file. Can be specified multiple times. Exactly one filter option must be specified: --all, --products, --prs, or --issues. - /// GitHub repository name (required when PRs or issues are specified as numbers) + /// Optional: GitHub repository name. Required when PRs or issues are specified as numbers. Falls back to bundle.repo in changelog.yml when not specified. /// [Command("remove")] public async Task Remove( From 4a0617872c0869965edb87da11ffaa059e2bae04 Mon Sep 17 00:00:00 2001 From: Lisa Cawley Date: Wed, 25 Feb 2026 18:46:47 -0800 Subject: [PATCH 9/9] Potential fix for pull request finding 'Constant condition' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> --- .../Elastic.Changelog/Bundling/ChangelogBundlingService.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs b/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs index 48c9dcb6f..1d02aa9e2 100644 --- a/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs +++ b/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs @@ -267,8 +267,8 @@ public async Task BundleChangelogs(IDiagnosticsCollector collector, Bundle } // Profile-level repo/owner takes precedence; fall back to bundle-level defaults - repo = profile.Repo ?? config?.Bundle?.Repo; - owner = profile.Owner ?? config?.Bundle?.Owner; + repo = profile.Repo ?? config.Bundle?.Repo; + owner = profile.Owner ?? config.Bundle?.Owner; mergedHideFeatures = profile.HideFeatures?.Count > 0 ? [.. profile.HideFeatures] : null; }