diff --git a/docs/cli-schema.json b/docs/cli-schema.json index 0358efd9ec..baa460f695 100644 --- a/docs/cli-schema.json +++ b/docs/cli-schema.json @@ -3163,7 +3163,7 @@ "name": "files", "type": "array", "required": false, - "summary": "Filter by changelog YAML paths (comma-separated), or a path to a newline-delimited file containing changelog paths. Can be specified multiple times. Forces local entry sourcing. This option is not supported in profile-based commands; pass a path list file as the second or third positional argument instead.", + "summary": "Filter by changelog YAML paths (comma-separated), or a path to a newline-delimited file containing changelog paths. Can be specified multiple times. When entries are sourced from the CDN, paths are matched to pool entries by file name and do not need to exist locally; with local sourcing (--force-local, --directory, or bundle.use_local_changelogs) the paths must exist on disk. This option is not supported in profile-based commands; pass a path list file as the second or third positional argument instead.", "repeatable": true, "elementType": "string" }, diff --git a/docs/cli/changelog/cmd-bundle.md b/docs/cli/changelog/cmd-bundle.md index 7df6979404..ee69b7fbc7 100644 --- a/docs/cli/changelog/cmd-bundle.md +++ b/docs/cli/changelog/cmd-bundle.md @@ -370,7 +370,7 @@ In profile mode, pass the same path list as a positional argument: docs-builder changelog bundle serverless-release 2026-07-07 ./docs/temp/changelog_files.txt ``` -`--files` / path-list selection always reads the named files from disk (local entry sourcing). It does not fetch entries from the CDN. `rules.bundle` still applies after selection. +`--files` / path-list selection follows the standard entry-sourcing rules. When entries are sourced from the CDN (the default when `bundle.repo` resolves), the listed paths are matched to CDN pool entries by file name and do not need to exist locally — useful for private repositories whose entries exist only in S3 and whose public copies have PR/issue references scrubbed, so PR-based filters cannot match. With local sourcing (`--force-local`, `--directory`, or `bundle.use_local_changelogs`), the listed files are read from disk and must exist. In either mode, a listed entry that cannot be found fails the run, and `rules.bundle` still applies after selection. ### Force local entry sourcing [changelog-bundle-force-local] @@ -382,7 +382,7 @@ docs-builder changelog bundle serverless-release 2026-07-07 ./docs/temp/prs.txt ``` `--force-local` is allowed in both option-based and profile-based commands. -Path-list / `--files` filters already force local sourcing, so `--force-local` is optional in that case. +Use it with path-list / `--files` filters when the listed files should be read from disk instead of matched against the CDN pool. ### Hide features [changelog-bundle-hide-features] diff --git a/docs/data/release-notes/bundle.md b/docs/data/release-notes/bundle.md index 8c22c829da..227604b725 100644 --- a/docs/data/release-notes/bundle.md +++ b/docs/data/release-notes/bundle.md @@ -209,6 +209,13 @@ For example, if the source of truth for what was shipped in each release is: docs/changelog/1770424401-adhoc-feature.yaml ``` + If you're bundling files from the CDN, use paths like this: + + ```txt + /changelog/elastic/kibana/main/1783971707-the-dashboards-and-visualizations-apis-are-now-gen.yaml + /changelog/elastic/kibana/main/247279.yaml + ``` + - automated release notes for GitHub releases: ```sh diff --git a/docs/data/release-notes/configure-ref.md b/docs/data/release-notes/configure-ref.md index 06169d5178..5e8add87d2 100644 --- a/docs/data/release-notes/configure-ref.md +++ b/docs/data/release-notes/configure-ref.md @@ -72,10 +72,10 @@ The authoring repo is resolved with the same precedence as `changelog upload`: ` Sourcing is decided per run: -- **Local folder.** Used when `bundle.use_local_changelogs: true`, when `--force-local` is passed, when `--files` / a path-list filter is used, when `--directory` is passed, or when the authoring repo cannot be resolved. The folder must contain the changelog files. -- **CDN (default when a repo resolves).** Used when the authoring repo resolves, local sourcing is not forced, and a CDN base URL is configured (`DOCS_BUILDER_CHANGELOG_CDN`, defaulting to the public distribution). The command fetches `changelog/{org}/{repo}/{branch}/registry.json` and the entries it lists, then applies the bundle's own product/PR/issue filters to the downloaded set. +- **Local folder.** Used when `bundle.use_local_changelogs: true`, when `--force-local` is passed, when `--directory` is passed, or when the authoring repo cannot be resolved. The folder must contain the changelog files. +- **CDN (default when a repo resolves).** Used when the authoring repo resolves, local sourcing is not forced, and a CDN base URL is configured (`DOCS_BUILDER_CHANGELOG_CDN`, defaulting to the public distribution). The command fetches `changelog/{org}/{repo}/{branch}/registry.json` and the entries it lists, then applies the bundle's own product/PR/issue/file filters to the downloaded set. Path-list / `--files` filters match pool entries by file name, so the listed paths do not need to exist locally. -Use `--force-local` for uncommon ad hoc runs that need the local folder without editing `changelog.yml`. Path-list / `--files` filters always force local sourcing because they select files by path on disk. +Use `--force-local` for uncommon ad hoc runs that need the local folder without editing `changelog.yml` — including path-list / `--files` runs that should read freshly authored files from disk instead of the CDN pool. Because entries are org/repo/branch-scoped, one repository can produce a bundle for a shared product (for example, `cloud-serverless`) while sourcing its own entries from `changelog/{org}/{repo}/{branch}/`, without that product appearing in the repository's `docset.yml`. The `{changelog}` directive's `:cdn:` mode still consumes product-scoped *bundles*, so a repository that also renders its own release notes declares each product under `release_notes` as before. diff --git a/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs b/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs index af98b09916..e96fb2c60b 100644 --- a/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs +++ b/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs @@ -40,7 +40,9 @@ public record BundleChangelogsArguments /// /// Explicit changelog YAML paths (or a path-list file) for the --files filter. - /// Mutually exclusive with other filter sources. Forces local entry sourcing. + /// Mutually exclusive with other filter sources. Follows the standard entry-sourcing gate: + /// when entries are sourced from the CDN the paths are matched to pool entries by file name, + /// otherwise they must exist on the local filesystem. /// public string[]? Files { get; init; } @@ -231,11 +233,13 @@ public async Task BundleChangelogs(IDiagnosticsCollector collector, Bundle // an org/repo/branch pool (changelog/{org}/{repo}/{branch}/...), so CDN sourcing keys off the // resolvable authoring repo (bundle.repo / --repo), with org and branch defaulting when unset — // not the bundle's target products. Fall back to the local folder when the user forces it - // (bundle.use_local_changelogs / --force-local / --files / --directory), the repo is unresolvable, + // (bundle.use_local_changelogs / --force-local / --directory), the repo is unresolvable, // or no CDN base is configured. This stays in lockstep with PlanBundleAsync's needs_network decision. + // The --files / path-list filter follows the same gate: in CDN mode the requested paths are + // matched to pool entries by file name, so private repos whose entries exist only in S3 (with + // PR/issue references scrubbed from the public copies) can still bundle by explicit selection. var useLocalChangelogs = (config?.Bundle?.UseLocalChangelogs ?? false) - || input.ForceLocal - || input.Files is { Length: > 0 }; + || input.ForceLocal; var authoringRepo = ChangelogRepoOwnerResolver.NormalizeRepo(input.Repo); var authoringOwner = ChangelogRepoOwnerResolver.ResolveOwner(input.Owner, input.Repo, DefaultOwner); var authoringBranch = string.IsNullOrWhiteSpace(input.Branch) ? DefaultBranch : input.Branch; @@ -253,14 +257,27 @@ public async Task BundleChangelogs(IDiagnosticsCollector collector, Bundle var prsToMatch = new HashSet(StringComparer.OrdinalIgnoreCase); var issuesToMatch = new HashSet(StringComparer.OrdinalIgnoreCase); IReadOnlyList? explicitFilePaths = null; + IReadOnlyList? requestedEntryNames = null; if (input.Files is { Length: > 0 }) { var fileFilterLoader = new FileFilterLoader(_fileSystem); - var fileFilterResult = await fileFilterLoader.LoadFilesAsync(collector, input.Files, input.Directory, ctx); - if (!fileFilterResult.IsValid) - return false; - explicitFilePaths = fileFilterResult.FilePaths; + if (useCdn) + { + // CDN mode: reduce the requested paths to entry file names (the pool is flat); the + // entries do not need to exist locally. + var namesResult = await fileFilterLoader.LoadFileNamesAsync(collector, input.Files, ctx); + if (!namesResult.IsValid) + return false; + requestedEntryNames = namesResult.FilePaths; + } + else + { + var fileFilterResult = await fileFilterLoader.LoadFilesAsync(collector, input.Files, input.Directory, ctx); + if (!fileFilterResult.IsValid) + return false; + explicitFilePaths = fileFilterResult.FilePaths; + } } else if (input.Prs is { Length: > 0 }) { @@ -289,7 +306,8 @@ public async Task BundleChangelogs(IDiagnosticsCollector collector, Bundle var filterCriteria = BuildFilterCriteria(input, prsToMatch, issuesToMatch); // Source and match changelog entries — from the CDN (default) or the local folder. - // Explicit --files / path-list selection always loads the named local paths (IncludeAll). + // Explicit --files / path-list selection bypasses content filters (IncludeAll): locally it loads + // the named paths, in CDN mode it selects pool entries by file name. var entryMatcher = new ChangelogEntryMatcher(_fileSystem, ReleaseNotesSerialization.GetEntryDeserializer(), _logger); ChangelogMatchResult matchResult; if (explicitFilePaths != null) @@ -303,7 +321,18 @@ public async Task BundleChangelogs(IDiagnosticsCollector collector, Bundle var contents = await FetchCdnEntriesAsync(collector, authoringOwner, authoringRepo, authoringBranch, ctx); if (contents == null) return false; - matchResult = entryMatcher.MatchChangelogContents(collector, contents, filterCriteria, ctx); + if (requestedEntryNames is not null) + { + var poolLabel = $"{authoringOwner}/{authoringRepo}/{authoringBranch}"; + var selected = SelectRequestedCdnEntries(collector, contents, requestedEntryNames, poolLabel); + if (selected == null) + return false; + _logger.LogInformation("Matching {Count} explicitly selected changelog entries from the CDN", selected.Count); + var filesCriteria = filterCriteria with { IncludeAll = true }; + matchResult = entryMatcher.MatchChangelogContents(collector, selected, filesCriteria, ctx); + } + else + matchResult = entryMatcher.MatchChangelogContents(collector, contents, filterCriteria, ctx); } else { @@ -684,9 +713,7 @@ private BundleChangelogsArguments ApplyConfigDefaults(BundleChangelogsArguments // active when the authoring repo resolves (profile/config bundle.repo), the user has not forced // local sourcing, and a CDN base is configured. var useLocalChangelogs = (config?.Bundle?.UseLocalChangelogs ?? false) - || input.ForceLocal - || input.Files is { Length: > 0 } - || await ProfileFilterForcesLocalAsync(input, ctx); + || input.ForceLocal; var explicitDirectory = !string.IsNullOrWhiteSpace(input.Directory); var authoringRepo = ChangelogRepoOwnerResolver.NormalizeRepo(input.Repo ?? profileDef?.Repo ?? config?.Bundle?.Repo); if (ShouldSourceFromCdn(authoringRepo, useLocalChangelogs: useLocalChangelogs, explicitDirectory: explicitDirectory)) @@ -824,7 +851,7 @@ private BundleChangelogsArguments ApplyConfigDefaults(BundleChangelogsArguments return byName.Select(kv => (kv.Key, kv.Value)).ToList(); } - /// Gate for repo-scoped CDN entry sourcing: true when the authoring repo resolves, local sourcing is not forced (bundle.use_local_changelogs/--force-local/--files/--directory), and a CDN base is configured. + /// Gate for repo-scoped CDN entry sourcing: true when the authoring repo resolves, local sourcing is not forced (bundle.use_local_changelogs/--force-local/--directory), and a CDN base is configured. private static bool ShouldSourceFromCdn(string? authoringRepo, bool useLocalChangelogs, bool explicitDirectory) { if (useLocalChangelogs || explicitDirectory || string.IsNullOrWhiteSpace(authoringRepo)) @@ -833,30 +860,46 @@ private static bool ShouldSourceFromCdn(string? authoringRepo, bool useLocalChan } /// - /// Detects whether a profile positional list file is a changelog path list (which forces local sourcing). - /// Used by so needs_network matches run-mode without emitting filter diagnostics. + /// Selects the CDN-sourced entries whose file names were explicitly requested via --files / a + /// path list. Every requested name must exist in the pool: the registry is the source of truth for + /// what was uploaded, so a missing name means the entry never reached S3 (or the name is wrong) and + /// silently shipping an incomplete bundle is worse than failing the run. Returns null after + /// emitting an error when any requested name is missing. /// - private async Task ProfileFilterForcesLocalAsync(BundleChangelogsArguments input, Cancel ctx) + private IReadOnlyList<(string FileName, string Content)>? SelectRequestedCdnEntries( + IDiagnosticsCollector collector, + IReadOnlyList<(string FileName, string Content)> contents, + IReadOnlyList requestedEntryNames, + string poolLabel) { - if (string.IsNullOrWhiteSpace(input.Profile)) - return false; - - var listPath = !string.IsNullOrWhiteSpace(input.ProfileReport) - ? input.ProfileReport - : input.ProfileArgument; - if (string.IsNullOrWhiteSpace(listPath) || !_fileSystem.File.Exists(listPath)) - return false; + var byName = new Dictionary(StringComparer.Ordinal); + foreach (var (fileName, content) in contents) + byName[fileName] = content; - if (_fileSystem.Path.GetExtension(listPath).ToLowerInvariant() is ".html" or ".htm") - return false; + var selected = new List<(string FileName, string Content)>(); + var missing = new List(); + var seen = new HashSet(StringComparer.Ordinal); + foreach (var name in requestedEntryNames) + { + if (!seen.Add(name)) + continue; + if (byName.TryGetValue(name, out var content)) + selected.Add((name, content)); + else + missing.Add(name); + } - var content = await _fileSystem.File.ReadAllTextAsync(listPath, ctx); - var lines = content - .Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) - .Where(l => !string.IsNullOrWhiteSpace(l)) - .ToArray(); + if (missing.Count > 0) + { + collector.EmitError(string.Empty, + $"Changelog entr{(missing.Count == 1 ? "y" : "ies")} not found in the CDN pool '{poolLabel}': {string.Join(", ", missing)}. " + + "Ensure the entries were uploaded (changelog upload), or pass --force-local / --directory to bundle local files instead."); + return null; + } - return lines.Length > 0 && lines.All(FileFilterLoader.IsYamlExtension); + _logger.LogInformation("Selected {Selected} of {Total} CDN entries by requested file name for {Pool}", + selected.Count, contents.Count, poolLabel); + return selected; } private bool ValidateInput(IDiagnosticsCollector collector, BundleChangelogsArguments input, bool requireDirectoryExists) diff --git a/src/services/Elastic.Changelog/Bundling/FileFilterLoader.cs b/src/services/Elastic.Changelog/Bundling/FileFilterLoader.cs index faac64486c..ada35a4be7 100644 --- a/src/services/Elastic.Changelog/Bundling/FileFilterLoader.cs +++ b/src/services/Elastic.Changelog/Bundling/FileFilterLoader.cs @@ -68,6 +68,54 @@ public async Task LoadFilesAsync( return new FileFilterResult { IsValid = true, FilePaths = resolved }; } + /// + /// Resolves into changelog entry file names for CDN-pool matching. + /// Values may be changelog YAML paths — reduced to their file name, without requiring local + /// existence, because the entries may exist only in S3 — or a newline-delimited path-list file + /// (which must exist locally to be read). + /// + public async Task LoadFileNamesAsync( + IDiagnosticsCollector collector, + string[]? files, + Cancel ctx) + { + var names = new List(); + + if (files is not { Length: > 0 }) + return new FileFilterResult { IsValid = true, FilePaths = names }; + + foreach (var rawValue in files) + { + if (string.IsNullOrWhiteSpace(rawValue)) + continue; + + var value = FilterLoaderUtilities.ExpandTilde(rawValue); + if (IsPathListFile(value)) + { + if (!await ReadPathListNamesAsync(collector, value, names, ctx)) + return new FileFilterResult { IsValid = false, FilePaths = names }; + continue; + } + + if (LooksLikeHttpUrl(value) || !IsYamlExtension(value)) + { + collector.EmitError(value, $"--files values must be changelog YAML paths (.yaml/.yml) or a newline-delimited path list file. Found: {rawValue}"); + return new FileFilterResult { IsValid = false, FilePaths = names }; + } + + if (!TryAddEntryName(collector, value, value, names)) + return new FileFilterResult { IsValid = false, FilePaths = names }; + } + + if (names.Count == 0) + { + collector.EmitError(string.Empty, "No changelog file names were resolved from --files"); + return new FileFilterResult { IsValid = false, FilePaths = names }; + } + + return new FileFilterResult { IsValid = true, FilePaths = names }; + } + /// /// Reads a newline-delimited path list and appends resolved changelog paths to . /// @@ -77,6 +125,56 @@ public async Task ReadPathListFileAsync( string? baseDirectory, List resolved, Cancel ctx) + { + var lines = await ReadListLinesAsync(collector, listFilePath, ctx); + if (lines == null) + return false; + + foreach (var line in lines) + { + if (!ValidatePathListLine(collector, listFilePath, line)) + return false; + + var path = ResolveChangelogPath(line, baseDirectory); + if (path == null) + { + EmitMissingFileError(collector, line, "--files"); + return false; + } + + resolved.Add(path); + } + + return true; + } + + /// + /// Reads a newline-delimited path list and appends the entry file names to , + /// without requiring the listed paths to exist locally (CDN-pool matching). + /// + private async Task ReadPathListNamesAsync( + IDiagnosticsCollector collector, + string listFilePath, + List names, + Cancel ctx) + { + var lines = await ReadListLinesAsync(collector, listFilePath, ctx); + if (lines == null) + return false; + + foreach (var line in lines) + { + if (!ValidatePathListLine(collector, listFilePath, line)) + return false; + + if (!TryAddEntryName(collector, line, listFilePath, names)) + return false; + } + + return true; + } + + private async Task ReadListLinesAsync(IDiagnosticsCollector collector, string listFilePath, Cancel ctx) { var content = await fileSystem.File.ReadAllTextAsync(listFilePath, ctx); var lines = content @@ -87,39 +185,45 @@ public async Task ReadPathListFileAsync( if (lines.Length == 0) { collector.EmitError(listFilePath, "Path list file is empty"); - return false; + return null; } - foreach (var line in lines) + return lines; + } + + private bool ValidatePathListLine(IDiagnosticsCollector collector, string listFilePath, string line) + { + if (LooksLikeHttpUrl(line)) { - if (LooksLikeHttpUrl(line)) - { - collector.EmitError( - listFilePath, - $"Path list file must contain changelog YAML paths (.yaml/.yml), not URLs. Found: {line}" - ); - return false; - } + collector.EmitError( + listFilePath, + $"Path list file must contain changelog YAML paths (.yaml/.yml), not URLs. Found: {line}" + ); + return false; + } - if (!IsYamlExtension(line)) - { - collector.EmitError( - listFilePath, - $"Path list file must contain changelog YAML paths (.yaml/.yml). Found: {line}" - ); - return false; - } + if (!IsYamlExtension(line)) + { + collector.EmitError( + listFilePath, + $"Path list file must contain changelog YAML paths (.yaml/.yml). Found: {line}" + ); + return false; + } - var path = ResolveChangelogPath(line, baseDirectory); - if (path == null) - { - EmitMissingFileError(collector, line, "--files"); - return false; - } + return true; + } - resolved.Add(path); + private bool TryAddEntryName(IDiagnosticsCollector collector, string value, string diagnosticSource, List names) + { + var name = fileSystem.Path.GetFileName(value.Trim()); + if (string.IsNullOrWhiteSpace(name)) + { + collector.EmitError(diagnosticSource, $"Could not derive a changelog entry file name from: {value}"); + return false; } + names.Add(name); return true; } diff --git a/src/tooling/docs-builder/Commands/ChangelogCommand.cs b/src/tooling/docs-builder/Commands/ChangelogCommand.cs index 09cf49d80c..bff225100f 100644 --- a/src/tooling/docs-builder/Commands/ChangelogCommand.cs +++ b/src/tooling/docs-builder/Commands/ChangelogCommand.cs @@ -570,7 +570,7 @@ async static (s, collector, state, ctx) => await s.CreateChangelog(collector, st /// 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. /// Filter by pull request URLs (comma-separated), or a path to a newline-delimited file containing fully-qualified GitHub PR 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. - /// Filter by changelog YAML paths (comma-separated), or a path to a newline-delimited file containing changelog paths. Can be specified multiple times. Forces local entry sourcing. This option is not supported in profile-based commands; pass a path list file as the second or third positional argument instead. + /// Filter by changelog YAML paths (comma-separated), or a path to a newline-delimited file containing changelog paths. Can be specified multiple times. When entries are sourced from the CDN, paths are matched to pool entries by file name and do not need to exist locally; with local sourcing (--force-local, --directory, or bundle.use_local_changelogs) the paths must exist on disk. This option is not supported in profile-based commands; pass a path list file as the second or third positional argument instead. /// Force local entry sourcing for this run (equivalent to bundle.use_local_changelogs: true without editing config). Allowed in profile-based commands. /// GitHub repository name for PR/issue numbers or --release-version. Falls back to bundle.repo or the product ID. This option is not supported in profile-based commands. The equivalent configuration options are bundle.repo or bundle.profiles.<name>.repo. /// URL or file path to a promotion report; extracts PR URLs as the filter. This option is not supported in profile-based commands. Pass the report as the second or third positional argument instead. diff --git a/tests/Elastic.Changelog.Tests/Changelogs/BundleFilesFilterTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/BundleFilesFilterTests.cs index 590def67ba..75f72a0b64 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/BundleFilesFilterTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/BundleFilesFilterTests.cs @@ -49,6 +49,10 @@ public class BundleFilesFilterTests : ChangelogTestBase lifecycle: ga """; + // language=json + private const string CdnRegistryJson = + """{ "schema_version": 1, "product": "elasticsearch", "bundles": [ { "file": "keep.yaml" }, { "file": "skip.yaml" } ] }"""; + public BundleFilesFilterTests(ITestOutputHelper output) : base(output) { ServiceWithConfig = new(LoggerFactory, ConfigurationContext, FileSystem); @@ -261,7 +265,7 @@ public async Task Bundle_WithFiles_RulesBundleStillApplies() } [Fact] - public async Task Bundle_WithFiles_ForcesLocalEvenWhenRepoResolves() + public async Task Bundle_WithFilesAndForceLocal_SourcesLocalEvenWhenRepoResolves() { var keep = FileSystem.Path.Join(_changelogDir, "keep.yaml"); await FileSystem.File.WriteAllTextAsync(keep, EntryKeep, TestContext.Current.CancellationToken); @@ -284,17 +288,150 @@ public async Task Bundle_WithFiles_ForcesLocalEvenWhenRepoResolves() { Config = configPath, Files = [keep], + ForceLocal = true, Output = output }; var result = await service.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); result.Should().BeTrue($"Errors: {string.Join("; ", Collector.Diagnostics.Select(d => d.Message))}"); - handler.RequestedPaths.Should().BeEmpty("--files must force local sourcing"); + handler.RequestedPaths.Should().BeEmpty("--force-local must not reach the CDN"); var bundle = await FileSystem.File.ReadAllTextAsync(output, TestContext.Current.CancellationToken); bundle.Should().Contain("Keep me"); } + [Fact] + public async Task Bundle_WithFiles_RepoResolves_MatchesCdnPoolByFileName() + { + // Entries exist only in the CDN pool (e.g. a private repo that uploads to S3 without keeping local + // copies, with PR/issue references scrubbed from the public copies). The --files filter must still + // work by matching the requested paths to pool entries by file name. + var configPath = await WriteRepoOnlyConfigAsync(); + var handler = CdnPoolHandler(); + var service = ServiceWithCdn(handler); + + var output = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml"); + var input = new BundleChangelogsArguments + { + Config = configPath, + Files = ["docs/changelog/keep.yaml"], + Output = output + }; + + var result = await service.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); + + result.Should().BeTrue($"Errors: {string.Join("; ", Collector.Diagnostics.Select(d => d.Message))}"); + handler.RequestedPaths.Should().Contain("/changelog/elastic/elasticsearch/main/registry.json"); + var bundle = await FileSystem.File.ReadAllTextAsync(output, TestContext.Current.CancellationToken); + bundle.Should().Contain("name: keep.yaml"); + bundle.Should().NotContain("name: skip.yaml"); + } + + [Fact] + public async Task Bundle_WithFiles_CdnPoolMissingRequestedName_FailsBundle() + { + var configPath = await WriteRepoOnlyConfigAsync(); + var service = ServiceWithCdn(CdnPoolHandler()); + + var input = new BundleChangelogsArguments + { + Config = configPath, + Files = ["never-uploaded.yaml"], + Output = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml") + }; + + 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("not found in the CDN pool") && d.Message.Contains("never-uploaded.yaml")); + } + + [Fact] + public async Task Bundle_WithProfile_PathListFile_RepoResolves_SourcesFromCdn() + { + // The cloud scenario from docs-eng-team#734: profile mode with a path list file whose entries exist + // only in S3. The list must select pool entries by file name instead of requiring local files. + var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); + FileSystem.Directory.CreateDirectory(outputDir); + var configContent = $""" + bundle: + output_directory: {outputDir} + repo: elasticsearch + profiles: + release: + output: "bundle.yaml" + """; + var configPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "changelog.yml"); + FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); + await FileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); + + var listFile = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "files.txt"); + FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(listFile)!); + await FileSystem.File.WriteAllTextAsync(listFile, "keep.yaml\n", TestContext.Current.CancellationToken); + + var handler = CdnPoolHandler(); + var service = ServiceWithCdn(handler); + + var input = new BundleChangelogsArguments + { + Config = configPath, + Profile = "release", + ProfileArgument = "9.3.0", + ProfileReport = listFile + }; + + var result = await service.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); + + result.Should().BeTrue($"Errors: {string.Join("; ", Collector.Diagnostics.Select(d => d.Message))}"); + handler.RequestedPaths.Should().Contain("/changelog/elastic/elasticsearch/main/registry.json"); + var bundle = await FileSystem.File.ReadAllTextAsync( + FileSystem.Path.Join(outputDir, "bundle.yaml"), TestContext.Current.CancellationToken); + bundle.Should().Contain("name: keep.yaml"); + bundle.Should().NotContain("name: skip.yaml"); + } + + private static StubHandler CdnPoolHandler() => new(req => + { + var path = req.RequestUri!.AbsolutePath; + if (path.EndsWith("/registry.json", StringComparison.Ordinal)) + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(CdnRegistryJson, System.Text.Encoding.UTF8, "application/json") + }; + if (path.EndsWith("keep.yaml", StringComparison.Ordinal)) + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(EntryKeep, System.Text.Encoding.UTF8, "text/yaml") + }; + if (path.EndsWith("skip.yaml", StringComparison.Ordinal)) + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(EntrySkip, System.Text.Encoding.UTF8, "text/yaml") + }; + return new HttpResponseMessage(HttpStatusCode.NotFound); + }); + + private ChangelogBundlingService ServiceWithCdn(StubHandler handler) + { + var fetcher = new CdnChangelogEntryFetcher(LoggerFactory, handler, sleep: (_, _) => Task.CompletedTask); + return new ChangelogBundlingService(LoggerFactory, ConfigurationContext, FileSystem, null, fetcher); + } + + private async Task WriteRepoOnlyConfigAsync() + { + // language=yaml + var configContent = + """ + bundle: + repo: elasticsearch + """; + var configPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "changelog.yml"); + FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); + await FileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); + return configPath; + } + [Fact] public async Task Bundle_WithForceLocal_SourcesLocalDespiteResolvableRepo() {