diff --git a/config/changelog.example.yml b/config/changelog.example.yml index 00c0aac89f..d1e78cd209 100644 --- a/config/changelog.example.yml +++ b/config/changelog.example.yml @@ -294,6 +294,9 @@ bundle: # output_products: "cloud-serverless {version}" # omit lifecycle # # output_products: "cloud-serverless {version} {lifecycle}" # derive (ga for ISO dates) # # output_products: "cloud-serverless {version} preview" # hardcode for exceptional releases + # # Optional: replace bundle.output_directory for this profile (same as --output as a directory). + # # The conventional {repo}-{product}-{version}.yaml name is written in this folder. + # # output_directory: docs/releases/cloud-serverless # # 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 diff --git a/docs/cli-schema.json b/docs/cli-schema.json index beff678d3b..30aa4f03b0 100644 --- a/docs/cli-schema.json +++ b/docs/cli-schema.json @@ -3246,7 +3246,7 @@ "name": "output", "type": "string", "required": false, - "summary": "Output path for the bundled changelog. A .yml/.yaml file is used as-is. A directory, or omitting this option, writes {repo}-{product}-{version}.yaml (from --repo / bundle.repo / git origin, --output-products then --input-products, then --release-version). Warns and writes changelog-bundle.yaml if product or version cannot be resolved, or {product}-{version}.yaml if no repo resolves. Not supported in profile-based commands (same convention from the profile)." + "summary": "Output path for the bundled changelog. A .yml/.yaml file is used as-is. A directory, or omitting this option, writes {repo}-{product}-{version}.yaml (from --repo / bundle.repo / git origin, --output-products then --input-products, then --release-version). Warns and writes changelog-bundle.yaml if product or version cannot be resolved, or {product}-{version}.yaml if no repo resolves. Not supported in profile-based commands; the equivalent configuration option is bundle.profiles.\u003Cname\u003E.output_directory (directory only; same convention for the file name)." }, { "role": "flag", diff --git a/docs/cli/changelog/cmd-bundle.md b/docs/cli/changelog/cmd-bundle.md index 98d0f33748..ed4ac7857b 100644 --- a/docs/cli/changelog/cmd-bundle.md +++ b/docs/cli/changelog/cmd-bundle.md @@ -35,9 +35,10 @@ bundle: output_products: "elasticsearch {version}" serverless-release: output_products: "cloud-serverless {version}" + output_directory: docs/releases/cloud-serverless ``` -The bundle's file name is derived by convention as `{repo}-{product}-{version}.yaml` from the authoring repository (`--repo`, then the profile's `repo`, then `bundle.repo`, then the git `origin`), the profile's primary output product, and the version argument. For example, `docs-builder changelog bundle serverless-release 2026-08-31` writes `docs/releases/elasticsearch-cloud-serverless-2026-08-31.yaml`. If no repository can be resolved, the command warns and falls back to `{product}-{version}.yaml`. Setting an explicit `output` pattern on a profile is a hard error, and no two profiles may share a primary output product — they would collide on the same conventional target. +The bundle's file name is derived by convention as `{repo}-{product}-{version}.yaml` from the authoring repository (`--repo`, then the profile's `repo`, then `bundle.repo`, then the git `origin`), the profile's primary output product, and the version argument. For example, `docs-builder changelog bundle elasticsearch-release 9.2.0` writes `docs/releases/elasticsearch-elasticsearch-9.2.0.yaml`. A profile `output_directory` replaces `bundle.output_directory` for that profile (the same as option-mode `--output` when it is a directory): `docs-builder changelog bundle serverless-release 2026-08-31` writes `docs/releases/cloud-serverless/elasticsearch-cloud-serverless-2026-08-31.yaml`. If no repository can be resolved, the command warns and falls back to `{product}-{version}.yaml`. Setting an explicit `output` pattern on a profile is a hard error, and no two profiles may share a primary output product — they would collide on the same conventional target. ## Option-based mode diff --git a/docs/cli/changelog/cmd-upload.md b/docs/cli/changelog/cmd-upload.md index 1f397d17bf..c5e4f5fccf 100644 --- a/docs/cli/changelog/cmd-upload.md +++ b/docs/cli/changelog/cmd-upload.md @@ -74,7 +74,7 @@ Use `--artifact-type` to choose what to upload: | Value | Uploads | Default directory | | ----- | ------- | ----------------- | -| `bundle` | Consolidated bundle YAML files | `bundle.output_directory` from `changelog.yml`, or `docs/releases` | +| `bundle` | Consolidated bundle YAML files | `bundle.output_directory` from `changelog.yml`, each profile's `output_directory` when set, or `docs/releases` | | `changelog` | Individual changelog entry YAML files | `bundle.directory` from `changelog.yml`, or `docs/changelog` | Keying differs by artifact type: @@ -126,10 +126,12 @@ If it's necessary to re-trigger downstream scrubbers without changing file conte Directory resolution order: -1. `--directory` — explicit override for this run -2. `changelog.yml` — `bundle.output_directory` (bundles) or `bundle.directory` (changelog entries) +1. `--directory` — explicit override for this run (that folder only) +2. `changelog.yml` — for bundles, `bundle.output_directory` plus each profile `output_directory`; for changelog entries, `bundle.directory` 3. Built-in default — `docs/releases` (bundles) or `docs/changelog` (changelog entries) +Each bundle directory is scanned non-recursively. A profile that writes under `docs/releases/cloud-serverless` is included because that path is listed as the profile's `output_directory`, not because the global folder is walked. + Use `--config` to point at a `changelog.yml` file other than `docs/changelog.yml`. ## Examples diff --git a/docs/data/release-notes/bundle.md b/docs/data/release-notes/bundle.md index 335d0c2262..79c1aa9cc2 100644 --- a/docs/data/release-notes/bundle.md +++ b/docs/data/release-notes/bundle.md @@ -78,13 +78,15 @@ bundle: profiles: serverless-report: output_products: "cloud-serverless {version}" <3> + output_directory: docs/releases/cloud-serverless <4> elasticsearch-release: output_products: "elasticsearch {version} {lifecycle}" ``` 1. The directory that contains changelog files. -2. The directory that contains changelog bundles. -3. The bundle's product metadata, which affects the rules that are applied and the product and version titles that ultimately appear in the documentation. If omitted, it's derived from all the changelogs in the bundle. The authoring repo (`bundle.repo` here) and the first product also determine the bundle's file name, which is derived by convention as `{repo}-{product}-{version}.yaml` under `output_directory`. +2. The default directory that contains changelog bundles. Profiles that omit `output_directory` write here. +3. The bundle's product metadata, which affects the rules that are applied and the product and version titles that ultimately appear in the documentation. If omitted, it's derived from all the changelogs in the bundle. The authoring repo (`bundle.repo` here) and the first product also determine the bundle's file name, which is derived by convention as `{repo}-{product}-{version}.yaml`. +4. Optional. Replaces `bundle.output_directory` for this profile (the same as option-mode `--output` when it is a directory). This profile writes `docs/releases/cloud-serverless/elasticsearch-cloud-serverless-{version}.yaml` so a `{changelog}` directive can point at that folder without mixing other products. ### Bundle by GitHub releases [profile-gh-release] diff --git a/docs/data/release-notes/configure-ref.md b/docs/data/release-notes/configure-ref.md index 702e95e6ce..9a4df134a2 100644 --- a/docs/data/release-notes/configure-ref.md +++ b/docs/data/release-notes/configure-ref.md @@ -49,7 +49,7 @@ These settings are relevant to one or all of the `changelog bundle`, `changelog | `bundle.branch` | Branch whose CDN changelog pool (`changelog/{org}/{repo}/{branch}/...`) entries are sourced from when bundling (default: `main`). Refer to [Entry sourcing](#bundle-entry-sourcing). | | `bundle.directory` | Input directory containing changelog YAML files (default: `docs/changelog`). | | `bundle.link_allow_repos` | List of `owner/repo` pairs whose PR/issue links are preserved. When set (including empty `[]`), links to unlisted repos become `# PRIVATE:` sentinels. | -| `bundle.output_directory` | Output directory for bundled files (default: `docs/releases`). Conventional `{repo}-{product}-{version}.yaml` names are written here in profile mode and in option mode when `--output` is omitted. Passing `--output` as a directory writes that same file name in the directory you specify instead. | +| `bundle.output_directory` | Output directory for bundled files (default: `docs/releases`). Conventional `{repo}-{product}-{version}.yaml` names are written here in profile mode (unless the profile sets `output_directory`) and in option mode when `--output` is omitted. Passing `--output` as a directory, or setting a profile `output_directory`, writes that same file name in the directory you specify instead. | | `bundle.owner` | Default GitHub repository owner (for example, `elastic`). Also the org segment of uploaded changelog-entry keys (`changelog/{org}/{repo}/{branch}/...`) and CDN entry sourcing. | | `bundle.release_dates` | When `true`, bundles include a `release-date` field (default: true). | | `bundle.repo` | Default GitHub repository name (for example, `elasticsearch`). Used by the `{changelog}` directive to generate correct PR and issue links, to scope uploaded changelog-entry keys (`changelog/{org}/{repo}/{branch}/...`) and CDN entry sourcing, and as the `{repo}` segment of bundle file names (`{repo}-{product}-{version}.yaml`). Only needed when the product ID doesn't match the GitHub repository name (or to override the git remote). | @@ -125,9 +125,13 @@ These settings are located in the `bundle.profiles.` section of the config `output` : Removed. Bundle output names are derived by convention as `{repo}-{product}-{version}.yaml` from the authoring repository (`--repo`, then the profile's `repo`, then `bundle.repo`, then the git `origin`), the profile's primary output product (the first product in `output_products`, or `products`), and the version argument. If no repository can be resolved, the command warns and falls back to `{product}-{version}.yaml`. Setting `output` on any profile is a hard error at bundle time; remove the field. -: When no primary product or version resolves (for example, a promotion-report invocation without a version argument), the output path falls back in order to: `bundle.output_directory/changelog-bundle.yaml` (if `bundle.output_directory` is configured), then `changelog-bundle.yaml` in the input directory. +: When no primary product or version resolves (for example, a promotion-report invocation without a version argument), the output path falls back in order to: the profile's [`output_directory`](#bundle-profiles) (if set), then `bundle.output_directory/changelog-bundle.yaml` (if `bundle.output_directory` is configured), then `changelog-bundle.yaml` in the input directory. : No two profiles in the same configuration may share a primary output product — they would resolve to the same `{repo}-{product}-{version}.yaml` target for any given version, which is also a hard error. +`output_directory` +: Directory for this profile's bundle file. Replaces [`bundle.output_directory`](#bundle-basic) for that profile the same way option-mode `--output` does when it is a directory: the conventional `{repo}-{product}-{version}.yaml` name is written in the folder you specify (for example `docs/releases/cloud-serverless`). The path is used as written (repo-relative); it is not joined as a child of the global `bundle.output_directory`. +: A `.yml` or `.yaml` file path is a hard error — that is what `output` used to allow. Omit this setting to keep writing under the global `bundle.output_directory`. + `output_products` : The bundle's `products` metadata, which affects the bundle rules that are applied and the product and version titles that ultimately appear in documentation. : Supports `{version}` and `{lifecycle}` placeholders. diff --git a/docs/syntax/changelog.md b/docs/syntax/changelog.md index 162d9f0930..f06ff051c9 100644 --- a/docs/syntax/changelog.md +++ b/docs/syntax/changelog.md @@ -354,7 +354,7 @@ docs/ └── release-notes.md # Page with :::{changelog} ``` -The `bundle.directory` and `bundle.output_directory` settings in `changelog.yml` apply to the `changelog bundle` and `changelog gh-release` CLI commands. The directive's bundles folder is controlled by its first argument or defaults to `changelog/bundles/` relative to the docset root. +The `bundle.directory` and `bundle.output_directory` settings in `changelog.yml` apply to the `changelog bundle` and `changelog gh-release` CLI commands. A profile `output_directory` replaces the global bundle output folder for that profile only. The directive's bundles folder is independent: it is the first argument, or `changelog/bundles/` relative to the docset root, and it is **not** recursive. Point the directive at the same folder the profile writes to (for example `docs/releases/cloud-serverless`) so the page only loads that product's bundles. ## Version ordering diff --git a/src/Elastic.Documentation.Configuration/Changelog/BundleConfiguration.cs b/src/Elastic.Documentation.Configuration/Changelog/BundleConfiguration.cs index 8378bb27ce..5648f262fd 100644 --- a/src/Elastic.Documentation.Configuration/Changelog/BundleConfiguration.cs +++ b/src/Elastic.Documentation.Configuration/Changelog/BundleConfiguration.cs @@ -95,6 +95,15 @@ public record BundleProfile [Obsolete("No longer supported: bundle output names are derived by convention as '{repo}-{product}-{version}.yaml' from the authoring repo and the profile's output_products. Setting 'output' is a hard error at bundle time.")] public string? Output { get; init; } + /// + /// Profile-specific output directory. Replaces + /// for this profile the same way option-mode --output as a directory replaces it. The + /// conventional {repo}-{product}-{version}.yaml name is joined onto this path. A + /// .yml/.yaml value is a hard error (use of free-form filenames is what + /// used to allow). + /// + public string? OutputDirectory { get; init; } + /// /// Output products pattern. When set, overrides the products array derived from matched changelogs. /// Supports {version} and {lifecycle} placeholders. diff --git a/src/Elastic.Documentation.Configuration/Changelog/ChangelogConfigurationLoader.cs b/src/Elastic.Documentation.Configuration/Changelog/ChangelogConfigurationLoader.cs index fef71d5f4b..20e8cb0ad0 100644 --- a/src/Elastic.Documentation.Configuration/Changelog/ChangelogConfigurationLoader.cs +++ b/src/Elastic.Documentation.Configuration/Changelog/ChangelogConfigurationLoader.cs @@ -598,6 +598,7 @@ BundleConfigurationYaml yaml #pragma warning disable CS0618 // Output stays parseable for one release cycle so ValidateProfileOutputs can emit an actionable error Output = kvp.Value.Output, #pragma warning restore CS0618 + OutputDirectory = kvp.Value.OutputDirectory, OutputProducts = kvp.Value.OutputProducts, Description = kvp.Value.Description, Repo = kvp.Value.Repo, diff --git a/src/Elastic.Documentation.Configuration/Changelog/ChangelogConfigurationYaml.cs b/src/Elastic.Documentation.Configuration/Changelog/ChangelogConfigurationYaml.cs index 09acb46e9d..f73c558f00 100644 --- a/src/Elastic.Documentation.Configuration/Changelog/ChangelogConfigurationYaml.cs +++ b/src/Elastic.Documentation.Configuration/Changelog/ChangelogConfigurationYaml.cs @@ -349,6 +349,11 @@ internal sealed record BundleProfileYaml /// public string? Output { get; set; } + /// + /// Profile-specific output directory. Replaces bundle.output_directory for this profile. + /// + public string? OutputDirectory { get; set; } + /// /// Output products pattern. Overrides the products array derived from matched changelogs. /// Supports {version} and {lifecycle} placeholders. diff --git a/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs b/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs index 9a790fb188..8935b9f46b 100644 --- a/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs +++ b/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs @@ -697,7 +697,7 @@ Cancel ctx input.Config ) ); - outputPath = JoinProfileOutputPath(config.Bundle.OutputDirectory, input.OutputDirectory, config.Bundle.Directory, fileName); + outputPath = JoinProfileOutputPath(config, input, fileName); } // Parse output_products pattern with version/lifecycle substitution @@ -1153,7 +1153,7 @@ Cancel ctx _fileSystem, new BundleOutputNameRequest(primaryProduct, planVersion, input.Repo, profileDef.Repo, config?.Bundle?.Repo, input.Config) ); - outputPath = JoinProfileOutputPath(config?.Bundle?.OutputDirectory, input.OutputDirectory, config?.Bundle?.Directory, fileName); + outputPath = JoinProfileOutputPath(config, input, fileName); } else outputPath = ResolveResolvedOutputPath(collector, input, config); @@ -1229,7 +1229,7 @@ private string ResolveResolvedOutputPath( var outputDir = !string.IsNullOrWhiteSpace(input.Output) ? input.Output - : config?.Bundle?.OutputDirectory + : ResolveConfiguredOutputDirectory(config, input) ?? input.OutputDirectory ?? input.Directory ?? config?.Bundle?.Directory @@ -1249,20 +1249,34 @@ private string ResolveResolvedOutputPath( } /// - /// Resolution order: bundle.output_directory → input.OutputDirectory (programmatic override) - /// → bundle.directory → CWD. + /// Profile output_directory replaces bundle.output_directory (same as option-mode + /// --output as a directory). Then input.OutputDirectory, bundle.directory, CWD. /// - private string JoinProfileOutputPath( - string? configOutputDirectory, - string? inputOutputDirectory, - string? configDirectory, - string fileName - ) + private string JoinProfileOutputPath(ChangelogConfiguration? config, BundleChangelogsArguments input, string fileName) { - var outputDir = configOutputDirectory ?? inputOutputDirectory ?? configDirectory ?? _fileSystem.Directory.GetCurrentDirectory(); + var outputDir = ResolveConfiguredOutputDirectory(config, input) + ?? input.OutputDirectory + ?? config?.Bundle?.Directory + ?? _fileSystem.Directory.GetCurrentDirectory(); return _fileSystem.Path.Join(outputDir, fileName).OptionalWindowsReplace(); } + /// + /// Profile output_directory when the invoked profile sets it; otherwise + /// bundle.output_directory. + /// + private static string? ResolveConfiguredOutputDirectory(ChangelogConfiguration? config, BundleChangelogsArguments input) + { + if ( + !string.IsNullOrWhiteSpace(input.Profile) + && config?.Bundle?.Profiles?.TryGetValue(input.Profile, out var profile) == true + && !string.IsNullOrWhiteSpace(profile.OutputDirectory) + ) + return profile.OutputDirectory; + + return config?.Bundle?.OutputDirectory; + } + /// The first concrete product id from a profile's output_products/products pattern. private static string? ResolvePrimaryProductFromProfile(BundleProfile profileDef) { @@ -1290,15 +1304,27 @@ private static bool ValidateProfileOutputs(IDiagnosticsCollector collector, Chan foreach (var (name, profile) in profiles) { #pragma warning disable CS0618 // intentionally reading the obsolete field to reject profiles that still set it - if (string.IsNullOrWhiteSpace(profile.Output)) - continue; + if (!string.IsNullOrWhiteSpace(profile.Output)) #pragma warning restore CS0618 - collector.EmitError( - string.Empty, - $"Profile '{name}': 'output' is no longer supported. Remove it — bundle output names are now derived by convention " + - $"as '{BundleOutputNaming.PrefixedConvention}' from the profile's output_products and authoring repo." - ); - valid = false; + { + collector.EmitError( + string.Empty, + $"Profile '{name}': 'output' is no longer supported. Remove it — bundle output names are now derived by convention " + + $"as '{BundleOutputNaming.PrefixedConvention}' from the profile's output_products and authoring repo." + ); + valid = false; + } + + if (BundleOutputNaming.IsYamlFilePath(profile.OutputDirectory)) + { + collector.EmitError( + string.Empty, + $"Profile '{name}': 'output_directory' must be a directory, not a file path. " + + $"The bundle file name is derived by convention as '{BundleOutputNaming.PrefixedConvention}'. " + + "To choose a folder, set output_directory the same way option-mode --output does when it is a directory." + ); + valid = false; + } } var collisions = profiles diff --git a/src/services/Elastic.Changelog/Uploading/ChangelogUploadService.cs b/src/services/Elastic.Changelog/Uploading/ChangelogUploadService.cs index 0f39047c7f..d4b042b772 100644 --- a/src/services/Elastic.Changelog/Uploading/ChangelogUploadService.cs +++ b/src/services/Elastic.Changelog/Uploading/ChangelogUploadService.cs @@ -98,25 +98,22 @@ public async Task Upload(IDiagnosticsCollector collector, ChangelogUploadA return true; } - var directory = args.ArtifactType is ArtifactType.Bundle or ArtifactType.Amend - ? await ResolveBundleDirectory(collector, args, ctx) - : await ResolveChangelogDirectory(collector, args, ctx); + var directories = args.ArtifactType is ArtifactType.Bundle or ArtifactType.Amend + ? await ResolveBundleScanDirectories(collector, args, ctx) + : [await ResolveChangelogDirectory(collector, args, ctx) ?? "docs/changelog"]; - if (directory == null) - return false; - - if (!_fileSystem.Directory.Exists(directory)) + var existing = directories.Where(d => !string.IsNullOrWhiteSpace(d) && _fileSystem.Directory.Exists(d)).ToList(); + if (existing.Count == 0) { - _logger.LogInformation("{ArtifactType} directory {Directory} does not exist; nothing to upload", args.ArtifactType, directory); + _logger.LogInformation( + "{ArtifactType} directory {Directory} does not exist; nothing to upload", + args.ArtifactType, + string.Join(", ", directories.Where(d => !string.IsNullOrWhiteSpace(d))) + ); return true; } - var targets = args.ArtifactType switch - { - ArtifactType.Bundle => DiscoverBundleUploadTargets(collector, directory), - ArtifactType.Amend => DiscoverAmendUploadTargets(collector, directory), - _ => DiscoverUploadTargets(collector, directory, args.Owner, args.Repo, args.Branch) - }; + var targets = DiscoverTargets(collector, args, existing); // Entry uploads abort (rather than no-op) when the repo cannot be resolved: the keys would be // unscoped and a silent skip would look like "nothing to upload". @@ -125,7 +122,11 @@ public async Task Upload(IDiagnosticsCollector collector, ChangelogUploadA if (targets.Count == 0) { - _logger.LogInformation("No {ArtifactType} files found to upload in {Directory}", args.ArtifactType, directory); + _logger.LogInformation( + "No {ArtifactType} files found to upload in {Directory}", + args.ArtifactType, + string.Join(", ", existing) + ); return true; } @@ -133,7 +134,7 @@ public async Task Upload(IDiagnosticsCollector collector, ChangelogUploadA "Found {Count} {ArtifactType} upload target(s) from {Directory}", targets.Count, args.ArtifactType, - directory + string.Join(", ", existing) ); using var defaultClient = s3Client == null ? new AmazonS3Client() : null; @@ -439,27 +440,83 @@ private List ReadProductsFromBundle(string filePath) } } - private async Task ResolveChangelogDirectory(IDiagnosticsCollector collector, ChangelogUploadArguments args, Cancel ctx) + private IReadOnlyList DiscoverTargets( + IDiagnosticsCollector collector, + ChangelogUploadArguments args, + IReadOnlyList directories + ) { - if (!string.IsNullOrWhiteSpace(args.Directory)) - return args.Directory; + var targets = new List(); + foreach (var directory in directories) + { + var found = args.ArtifactType switch + { + ArtifactType.Bundle => DiscoverBundleUploadTargets(collector, directory), + ArtifactType.Amend => DiscoverAmendUploadTargets(collector, directory), + _ => DiscoverUploadTargets(collector, directory, args.Owner, args.Repo, args.Branch) + }; + targets.AddRange(found); + } - if (_configLoader == null) - return "docs/changelog"; + return targets; + } - var config = await _configLoader.LoadChangelogConfiguration(collector, args.Config, ctx); - return config?.Bundle?.Directory ?? "docs/changelog"; + /// + /// Directories to scan for bundle/amend YAML: explicit --directory, else + /// bundle.output_directory plus each profile output_directory, else + /// bundle.directory, else docs/releases. Each directory is scanned + /// . + /// + internal static IReadOnlyList CollectBundleScanDirectories(string? explicitDirectory, ChangelogConfiguration? config) + { + if (!string.IsNullOrWhiteSpace(explicitDirectory)) + return [explicitDirectory]; + + var dirs = new List(); + var seen = new HashSet(StringComparer.Ordinal); + AddUnique(dirs, seen, config?.Bundle?.OutputDirectory); + if (config?.Bundle?.Profiles != null) + { + foreach (var profile in config.Bundle.Profiles.Values) + AddUnique(dirs, seen, profile.OutputDirectory); + } + + if (dirs.Count == 0) + AddUnique(dirs, seen, config?.Bundle?.Directory); + if (dirs.Count == 0) + AddUnique(dirs, seen, "docs/releases"); + return dirs; } - private async Task ResolveBundleDirectory(IDiagnosticsCollector collector, ChangelogUploadArguments args, Cancel ctx) + private static void AddUnique(List dirs, HashSet seen, string? directory) + { + if (string.IsNullOrWhiteSpace(directory) || !seen.Add(directory)) + return; + dirs.Add(directory); + } + + private async Task> ResolveBundleScanDirectories( + IDiagnosticsCollector collector, + ChangelogUploadArguments args, + Cancel ctx + ) + { + if (!string.IsNullOrWhiteSpace(args.Directory) || _configLoader == null) + return CollectBundleScanDirectories(args.Directory, null); + + var config = await _configLoader.LoadChangelogConfiguration(collector, args.Config, ctx).ConfigureAwait(false); + return CollectBundleScanDirectories(null, config); + } + + private async Task ResolveChangelogDirectory(IDiagnosticsCollector collector, ChangelogUploadArguments args, Cancel ctx) { if (!string.IsNullOrWhiteSpace(args.Directory)) return args.Directory; if (_configLoader == null) - return "docs/releases"; + return "docs/changelog"; var config = await _configLoader.LoadChangelogConfiguration(collector, args.Config, ctx); - return config?.Bundle?.OutputDirectory ?? config?.Bundle?.Directory ?? "docs/releases"; + return config?.Bundle?.Directory ?? "docs/changelog"; } } diff --git a/src/tooling/docs-builder/Commands/ChangelogCommand.cs b/src/tooling/docs-builder/Commands/ChangelogCommand.cs index 8878dc3628..10d5614a9e 100644 --- a/src/tooling/docs-builder/Commands/ChangelogCommand.cs +++ b/src/tooling/docs-builder/Commands/ChangelogCommand.cs @@ -756,7 +756,7 @@ public async Task Note( /// Explicit release date for the bundle in YYYY-MM-DD format. Overrides auto-population behaviour. Mutually exclusive with --no-release-date. This option is not supported in profile-based commands; use option-based mode, or set bundle.release_dates in configuration to control auto-population. /// Filter by products in format "product target lifecycle, ..." (for example, "cloud-serverless 2025-12-02 ga, cloud-serverless 2025-12-06 beta"). All three parts are required but can be wildcards (*). A non-wildcard target matches products[].versions (changelog note) or a legacy target; not supported when sourcing from the CDN. This option is not supported in profile-based commands. The equivalent configuration option is bundle.profiles.<name>.products. /// Filter by issue URLs (comma-separated), or a path to a newline-delimited file containing fully-qualified GitHub issue URLs. Can be specified multiple times. This option is not supported in profile-based commands. Pass a promotion report as the second or third positional argument instead, or set source: github_release on the profile. - /// Output path for the bundled changelog. A .yml/.yaml file is used as-is. A directory, or omitting this option, writes {repo}-{product}-{version}.yaml (from --repo / bundle.repo / git origin, --output-products then --input-products, then --release-version). Warns and writes changelog-bundle.yaml if product or version cannot be resolved, or {product}-{version}.yaml if no repo resolves. Not supported in profile-based commands (same convention from the profile). + /// Output path for the bundled changelog. A .yml/.yaml file is used as-is. A directory, or omitting this option, writes {repo}-{product}-{version}.yaml (from --repo / bundle.repo / git origin, --output-products then --input-products, then --release-version). Warns and writes changelog-bundle.yaml if product or version cannot be resolved, or {product}-{version}.yaml if no repo resolves. Not supported in profile-based commands; the equivalent configuration option is bundle.profiles.<name>.output_directory (directory only; same convention for the file name). /// Explicitly set the products array in the output file in format "product target lifecycle, ...". This option is not supported in profile-based commands. The equivalent configuration option is bundle.profiles.<name>.output_products. /// GitHub repository owner for PR/issue numbers or --release-version. Falls back to bundle.owner or "elastic". This option is not supported in profile-based commands. The equivalent configuration options are bundle.owner or bundle.profiles.<name>.owner. /// Branch whose CDN changelog entry pool (changelog/{org}/{repo}/{branch}/...) is sourced from. Falls back to bundle.branch or "main". This option is not supported in profile-based commands. The equivalent configuration options are bundle.branch or bundle.profiles.<name>.branch. diff --git a/tests/Elastic.Changelog.Tests/Changelogs/BundleOutputConventionTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/BundleOutputConventionTests.cs index b6a7b31d2f..70f8aad0fc 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/BundleOutputConventionTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/BundleOutputConventionTests.cs @@ -522,7 +522,7 @@ public async Task OptionMode_PlanMatchesRunPath() var plan = await Service().PlanBundleAsync(Collector, input, hasReleaseVersion: false, TestContext.Current.CancellationToken); plan.Should().NotBeNull(); - plan! + plan .OutputPath .Should() .Be(FileSystem.Path.Join(_changelogDir, "kibana-cloud-serverless-2026-08-27.yaml").OptionalWindowsReplace()); @@ -534,6 +534,111 @@ public async Task OptionMode_PlanMatchesRunPath() FileSystem.File.Exists(plan.OutputPath).Should().BeTrue("--plan output_path matches the file bundle writes"); } + [Fact] + public async Task ProfileOutputDirectory_WritesConventionalNameInThatDirectory() + { + var configPath = await WriteConfig( + """ + bundle: + directory: CHANGELOG_DIR + output_directory: CHANGELOG_DIR + use_local_changelogs: true + repo: kibana + profiles: + kibana-release: + products: "elasticsearch {version} *" + output_products: "kibana {version}" + serverless-release: + products: "elasticsearch {version} *" + output_products: "cloud-serverless {version}" + output_directory: CHANGELOG_DIR/cloud-serverless + """ + ); + + var serverlessInput = new BundleChangelogsArguments + { + Profile = "serverless-release", + ProfileArgument = "9.3.0", + Config = configPath + }; + var serverlessResult = await Service().BundleChangelogs(Collector, serverlessInput, TestContext.Current.CancellationToken); + serverlessResult.Should().BeTrue( + $"Errors: {string.Join("; ", Collector.Diagnostics.Where(d => d.Severity == Severity.Error).Select(d => d.Message))}" + ); + FileSystem + .File + .Exists(FileSystem.Path.Join(_changelogDir, "cloud-serverless", "kibana-cloud-serverless-9.3.0.yaml")) + .Should() + .BeTrue("profile output_directory replaces bundle.output_directory like --output as a directory"); + + var kibanaInput = new BundleChangelogsArguments { Profile = "kibana-release", ProfileArgument = "9.3.0", Config = configPath }; + var kibanaResult = await Service().BundleChangelogs(Collector, kibanaInput, TestContext.Current.CancellationToken); + kibanaResult.Should().BeTrue( + $"Errors: {string.Join("; ", Collector.Diagnostics.Where(d => d.Severity == Severity.Error).Select(d => d.Message))}" + ); + FileSystem + .File + .Exists(FileSystem.Path.Join(_changelogDir, "kibana-kibana-9.3.0.yaml")) + .Should() + .BeTrue("a sibling profile without output_directory still uses the global directory"); + } + + [Fact] + public async Task Plan_ProfileOutputDirectory_MatchesRun() + { + var configPath = await WriteConfig( + """ + bundle: + directory: CHANGELOG_DIR + output_directory: CHANGELOG_DIR + repo: kibana + profiles: + serverless-release: + products: "elasticsearch {version} *" + output_products: "cloud-serverless {version}" + output_directory: CHANGELOG_DIR/cloud-serverless + """ + ); + + var input = new BundleChangelogsArguments { Profile = "serverless-release", ProfileArgument = "9.3.0", Config = configPath }; + var plan = await Service().PlanBundleAsync(Collector, input, hasReleaseVersion: false, TestContext.Current.CancellationToken); + + plan.Should().NotBeNull(); + plan + .OutputPath + .Should() + .Be(FileSystem.Path.Join(_changelogDir, "cloud-serverless", "kibana-cloud-serverless-9.3.0.yaml").OptionalWindowsReplace()); + } + + [Fact] + public async Task ProfileOutputDirectory_YamlFilePath_EmitsHardError() + { + var configPath = await WriteConfig( + """ + bundle: + directory: CHANGELOG_DIR + use_local_changelogs: true + profiles: + es-release: + products: "elasticsearch {version} *" + output_directory: CHANGELOG_DIR/elasticsearch-9.3.0.yaml + """ + ); + + var input = new BundleChangelogsArguments { Profile = "es-release", ProfileArgument = "9.3.0", Config = configPath }; + var result = await Service().BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); + + result.Should().BeFalse(); + Collector + .Diagnostics + .Should() + .Contain( + d => d.Severity == Severity.Error && d.Message.Contains("'output_directory' must be a directory") && d.Message.Contains( + "{repo}-{product}-{version}.yaml" + ) + ); + } + [Fact] public void ResolveVersion_PrefersOutputProductsThenInputThenReleaseTag() { diff --git a/tests/Elastic.Changelog.Tests/Changelogs/BundlePlanTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/BundlePlanTests.cs index 8dfdcef4af..04f37079b6 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/BundlePlanTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/BundlePlanTests.cs @@ -83,6 +83,37 @@ public async Task Plan_ProfileMode_RepoResolvable_ReturnsNeedsNetwork() result.CdnUrl.Should().Be("https://d10xozp44eyz7q.cloudfront.net/bundle/elasticsearch/elasticsearch-elasticsearch-9.2.0.yaml"); } + [Fact] + public async Task Plan_ProfileOutputDirectory_JoinsConventionalName() + { + var configContent = + """ + bundle: + output_directory: docs/releases + repo: kibana + profiles: + serverless-release: + output_products: "cloud-serverless {version}" + output_directory: docs/releases/cloud-serverless + """; + var configPath = await CreateConfigAsync(configContent); + + var input = new BundleChangelogsArguments { Profile = "serverless-release", ProfileArgument = "2026-08-27", Config = configPath }; + + var result = await Service.PlanBundleAsync(Collector, input, hasReleaseVersion: false, TestContext.Current.CancellationToken); + + result.Should().NotBeNull(); + result + .OutputPath + .Should() + .EndWith( + FileSystem + .Path + .Join("docs", "releases", "cloud-serverless", "kibana-cloud-serverless-2026-08-27.yaml") + .OptionalWindowsReplace() + ); + } + [Fact] public async Task Plan_ProfileMode_NoRepo_ReturnsNoNetwork() { diff --git a/tests/Elastic.Changelog.Tests/Uploading/ChangelogUploadServiceTests.cs b/tests/Elastic.Changelog.Tests/Uploading/ChangelogUploadServiceTests.cs index 09abe0f632..323dd9dce9 100644 --- a/tests/Elastic.Changelog.Tests/Uploading/ChangelogUploadServiceTests.cs +++ b/tests/Elastic.Changelog.Tests/Uploading/ChangelogUploadServiceTests.cs @@ -11,6 +11,7 @@ using Elastic.Changelog.Tests.Changelogs; using Elastic.Changelog.Uploading; using Elastic.Documentation.Configuration; +using Elastic.Documentation.Configuration.Changelog; using Elastic.Documentation.Configuration.ReleaseNotes; using Elastic.Documentation.FileSystems; using Elastic.Documentation.ReleaseNotes; @@ -645,6 +646,86 @@ public void DiscoverBundleUploadTargets_MapsToArtifactRootKey() _collector.Errors.Should().Be(0); } + [Fact] + public void CollectBundleScanDirectories_IncludesGlobalAndProfileDirectories() + { + var config = new ChangelogConfiguration + { + Bundle = new BundleConfiguration + { + OutputDirectory = "docs/releases", + Profiles = new Dictionary + { + ["kibana-release"] = new(), + ["serverless-release"] = new() { OutputDirectory = "docs/releases/cloud-serverless" } + } + } + }; + + ChangelogUploadService.CollectBundleScanDirectories(null, config).Should().Equal("docs/releases", "docs/releases/cloud-serverless"); + } + + [Fact] + public void CollectBundleScanDirectories_ExplicitDirectory_IgnoresConfig() + { + var config = new ChangelogConfiguration { Bundle = new BundleConfiguration { OutputDirectory = "docs/releases" } }; + + ChangelogUploadService.CollectBundleScanDirectories("custom/out", config).Should().Equal("custom/out"); + } + + [Fact] + public void DiscoverBundleUploadTargets_ProfileSubdirectory_IsFoundWhenThatDirectoryIsScanned() + { + var root = _mockFileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); + var globalDir = _mockFileSystem.Path.Join(root, "docs", "releases"); + var profileDir = _mockFileSystem.Path.Join(globalDir, "cloud-serverless"); + _mockFileSystem.Directory.CreateDirectory(globalDir); + _mockFileSystem.Directory.CreateDirectory(profileDir); + + // language=yaml + const string bundleYaml = + """ + products: + - product: cloud-serverless + target: 2026-08-27 + entries: [] + """; + var nestedPath = _mockFileSystem.Path.Join(profileDir, "kibana-cloud-serverless-2026-08-27.yaml"); + _mockFileSystem.AddFile(nestedPath, new MockFileData(bundleYaml)); + _mockFileSystem.AddFile( + _mockFileSystem.Path.Join(globalDir, "kibana-kibana-9.3.0.yaml"), + new MockFileData( + """ + products: + - product: kibana + target: 9.3.0 + entries: [] + """ + ) + ); + + _service + .DiscoverBundleUploadTargets(_collector, globalDir) + .Should() + .ContainSingle(t => t.S3Key.Contains("kibana-kibana-9.3.0.yaml")); + _service.DiscoverBundleUploadTargets(_collector, globalDir).Should().NotContain(t => t.LocalPath == nestedPath); + + var config = new ChangelogConfiguration + { + Bundle = new BundleConfiguration + { + OutputDirectory = globalDir, + Profiles = new Dictionary { ["serverless-release"] = new() { OutputDirectory = profileDir } } + } + }; + var scanDirs = ChangelogUploadService.CollectBundleScanDirectories(null, config); + var targets = scanDirs.SelectMany(d => _service.DiscoverBundleUploadTargets(_collector, d)).ToList(); + + targets.Should().Contain(t => t.LocalPath == nestedPath); + targets.Should().Contain(t => t.S3Key == "bundle/cloud-serverless/kibana-cloud-serverless-2026-08-27.yaml"); + targets.Should().Contain(t => t.S3Key == "bundle/kibana/kibana-kibana-9.3.0.yaml"); + } + [Fact] public void DiscoverBundleUploadTargets_MultipleProducts_CreatesTargetPerProduct() {