From 30d5c3654faad205875ba8a1a28ceebe8f999377 Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Tue, 25 Aug 2026 12:57:01 +0200 Subject: [PATCH 1/4] fix(changelog): restrict filename strategy to pr; error on issue/timestamp changelog entries are always keyed by PR number from now on. - `filename: issue` and `filename: timestamp` in changelog.yml now emit errors pointing at `changelog note` for PR-less items - Default flips from `Timestamp` to `Pr` (all seven onboarded repos already use PR-number file names) - `--use-issue-number` CLI flag errors with a clear migration message; `--use-pr-number` remains but is now a no-op (always true) - `ChangelogFileWriter.GenerateFilename` drops the issue and timestamp branches; a PR-less call with no resolvable PR number now errors instead of silently falling back to a timestamp slug - Test fixtures updated: tests that exercised incidental filename generation now supply an explicit PR URL; config tests updated to reflect the new default and the new error cases Co-Authored-By: Claude Sonnet 4.6 --- .../Changelog/ChangelogConfiguration.cs | 2 +- .../Changelog/ChangelogConfigurationLoader.cs | 18 +++++- .../Creation/ChangelogCreationService.cs | 14 +---- .../Creation/ChangelogFileWriter.cs | 43 +++------------ .../docs-builder/Commands/ChangelogCommand.cs | 28 ++-------- .../Changelogs/ChangelogConfigurationTests.cs | 36 ++++++++---- .../Changelogs/Create/BasicInputTests.cs | 3 + .../Create/FlagsAndFeaturesTests.cs | 3 + .../Changelogs/Create/PrIntegrationTests.cs | 7 +-- .../Changelogs/Create/TitleProcessingTests.cs | 1 + .../Changelogs/Create/ValidationTests.cs | 2 + .../Creation/ChangelogCreationServiceTests.cs | 1 + .../Creation/FilenameStrategyTests.cs | 55 +++---------------- 13 files changed, 75 insertions(+), 138 deletions(-) diff --git a/src/Elastic.Documentation.Configuration/Changelog/ChangelogConfiguration.cs b/src/Elastic.Documentation.Configuration/Changelog/ChangelogConfiguration.cs index f6bd64127d..8b22f66703 100644 --- a/src/Elastic.Documentation.Configuration/Changelog/ChangelogConfiguration.cs +++ b/src/Elastic.Documentation.Configuration/Changelog/ChangelogConfiguration.cs @@ -124,7 +124,7 @@ public record ChangelogConfiguration /// Filename strategy for generated changelog files. /// Controls how files created by 'changelog add' are named. /// - public FilenameStrategy Filename { get; init; } = FilenameStrategy.Timestamp; + public FilenameStrategy Filename { get; init; } = FilenameStrategy.Pr; /// /// Bundle configuration with profiles and defaults. diff --git a/src/Elastic.Documentation.Configuration/Changelog/ChangelogConfigurationLoader.cs b/src/Elastic.Documentation.Configuration/Changelog/ChangelogConfigurationLoader.cs index 733fc51586..beb0a2dfbe 100644 --- a/src/Elastic.Documentation.Configuration/Changelog/ChangelogConfigurationLoader.cs +++ b/src/Elastic.Documentation.Configuration/Changelog/ChangelogConfigurationLoader.cs @@ -303,15 +303,27 @@ internal static ChangelogConfigurationYaml DeserializeConfiguration(string yaml) }; // Process filename strategy - var filenameStrategy = FilenameStrategy.Timestamp; + var filenameStrategy = FilenameStrategy.Pr; if (!string.IsNullOrWhiteSpace(yamlConfig.Filename)) { if (!FilenameStrategyExtensions.TryParse(yamlConfig.Filename, out var parsed, ignoreCase: true, allowMatchingMetadataAttribute: true)) { - var valid = string.Join(", ", FilenameStrategyExtensions.GetValues().Select(v => v.ToStringFast(true))); - collector.EmitError(configPath, $"filename: '{yamlConfig.Filename}' is not valid. Use one of: {valid}"); + collector.EmitError(configPath, $"filename: '{yamlConfig.Filename}' is not valid. The only supported value is 'pr'. Changelog entries are keyed by PR number; for items with no PR use 'changelog note'."); return null; } + + if (parsed == FilenameStrategy.Timestamp) + { + collector.EmitError(configPath, "filename: 'timestamp' is no longer supported. Changelog entries are keyed by PR number; for items with no PR use 'changelog note'."); + return null; + } + + if (parsed == FilenameStrategy.Issue) + { + collector.EmitError(configPath, "filename: 'issue' is no longer supported. Changelog entries are keyed by PR number; for items with no PR use 'changelog note'."); + return null; + } + filenameStrategy = parsed; } diff --git a/src/services/Elastic.Changelog/Creation/ChangelogCreationService.cs b/src/services/Elastic.Changelog/Creation/ChangelogCreationService.cs index 7a435eb8d6..3914293fe0 100644 --- a/src/services/Elastic.Changelog/Creation/ChangelogCreationService.cs +++ b/src/services/Elastic.Changelog/Creation/ChangelogCreationService.cs @@ -37,7 +37,6 @@ public record CreateChangelogArguments public string? Output { get; init; } public string? Config { get; init; } public bool UsePrNumber { get; init; } - public bool UseIssueNumber { get; init; } public bool? StripTitlePrefix { get; init; } /// /// Whether to extract release note text from PR/issue descriptions for the entry description. null = use config default. @@ -149,22 +148,13 @@ public async Task CreateChangelog(IDiagnosticsCollector collector, CreateC internal static CreateChangelogArguments ApplyConfigDefaults(CreateChangelogArguments input, ChangelogConfiguration config) { - var usePrNumber = input.UsePrNumber; - var useIssueNumber = input.UseIssueNumber; - - if (!usePrNumber && !useIssueNumber) - { - usePrNumber = config.Filename == FilenameStrategy.Pr; - useIssueNumber = config.Filename == FilenameStrategy.Issue; - } - + // Filename strategy is always Pr now; UsePrNumber is kept for backward compat but is effectively always true. return input with { ExtractReleaseNotes = input.ExtractReleaseNotes ?? config.Extract.ReleaseNotes, ExtractIssues = input.ExtractIssues ?? config.Extract.Issues, StripTitlePrefix = input.StripTitlePrefix ?? config.Extract.StripTitlePrefix, - UsePrNumber = usePrNumber, - UseIssueNumber = useIssueNumber + UsePrNumber = true }; } diff --git a/src/services/Elastic.Changelog/Creation/ChangelogFileWriter.cs b/src/services/Elastic.Changelog/Creation/ChangelogFileWriter.cs index ce3ba610d2..05662b1275 100644 --- a/src/services/Elastic.Changelog/Creation/ChangelogFileWriter.cs +++ b/src/services/Elastic.Changelog/Creation/ChangelogFileWriter.cs @@ -64,7 +64,7 @@ public async Task WriteChangelogAsync( private string GenerateFilename(IDiagnosticsCollector collector, CreateChangelogArguments input) { - if (input.UsePrNumber && input.Prs is { Length: > 0 }) + if (input.Prs is { Length: > 0 }) { var numbers = input.Prs .Select(pr => ChangelogTextUtilities.ExtractPrNumber(pr, input.Owner, input.Repo)) @@ -82,43 +82,14 @@ private string GenerateFilename(IDiagnosticsCollector collector, CreateChangelog // Too many PRs: use compact format to avoid path-too-long errors return $"{numbers[0]}-to-{numbers[^1]}-{numbers.Count}-prs.yaml"; } - - collector.EmitWarning(string.Empty, $"Failed to extract PR numbers from PRs. Falling back to timestamp-based filename."); - } - - if (input.UseIssueNumber && input.Issues is { Length: > 0 }) - { - var numbers = input.Issues - .Select(issue => ChangelogTextUtilities.ExtractIssueNumber(issue, input.Owner, input.Repo)) - .Where(n => n.HasValue) - .Select(n => n!.Value) - .Distinct() - .OrderBy(n => n) - .ToList(); - - if (numbers.Count > 0) - { - var joined = $"{string.Join("-", numbers)}.yaml"; - if (joined.Length <= MaxFilenameLength + 5) - return joined; - return $"{numbers[0]}-to-{numbers[^1]}-{numbers.Count}-issues.yaml"; - } - - collector.EmitWarning(string.Empty, "Failed to extract issue numbers from issues. Falling back to timestamp-based filename."); } - // Default: timestamp-slug.yaml - var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); - var firstPr = input.Prs is { Length: > 0 } ? input.Prs[0] : null; - var firstIssue = input.Issues is { Length: > 0 } ? input.Issues[0] : null; - var slug = string.IsNullOrWhiteSpace(input.Title) - ? firstPr != null - ? $"pr-{firstPr.Replace("/", "-").Replace(":", "-")}" - : firstIssue != null - ? $"issue-{firstIssue.Replace("/", "-").Replace(":", "-")}" - : "changelog" - : ChangelogTextUtilities.SanitizeFilename(input.Title); - return $"{timestamp}-{slug}.yaml"; + collector.EmitError(string.Empty, + "Could not derive a PR number from the provided --prs values. " + + "Changelog entries must be anchored to a PR number. " + + "For items with no PR use 'changelog note' instead."); + // Return a placeholder; the caller checks the collector for errors. + return "changelog.yaml"; } private static ChangelogEntry BuildChangelogData(CreateChangelogArguments input) diff --git a/src/tooling/docs-builder/Commands/ChangelogCommand.cs b/src/tooling/docs-builder/Commands/ChangelogCommand.cs index 1d4cd6df3d..f62f508fde 100644 --- a/src/tooling/docs-builder/Commands/ChangelogCommand.cs +++ b/src/tooling/docs-builder/Commands/ChangelogCommand.cs @@ -481,29 +481,12 @@ async static (s, collector, state, ctx) => await s.CreateChangelogsFromRelease(c // Use provided products or empty list (service will infer from repo/config if empty) var resolvedProducts = (IReadOnlyList?)products ?? []; - if (usePrNumber && useIssueNumber) + // Changelog entries are always keyed by PR number. --use-issue-number was removed. + if (useIssueNumber) { - collector.EmitError(string.Empty, "--use-pr-number and --use-issue-number are mutually exclusive; specify only one."); - _ = collector.StartAsync(ctx); - await collector.WaitForDrain(); - await collector.StopAsync(ctx); - return 1; - } - - // --use-pr-number with --issues is allowed: PRs can be extracted from the issue body (Fixed by #123, etc.) - if (usePrNumber && (parsedPrs == null || parsedPrs.Length == 0) && (parsedIssues == null || parsedIssues.Length == 0)) - { - collector.EmitError(string.Empty, "--use-pr-number requires --prs, --issues, or --report to be specified."); - _ = collector.StartAsync(ctx); - await collector.WaitForDrain(); - await collector.StopAsync(ctx); - return 1; - } - - // --use-issue-number with --prs is allowed: issues can be extracted from the PR body (Fixes #123, etc.) - if (useIssueNumber && (parsedIssues == null || parsedIssues.Length == 0) && (parsedPrs == null || parsedPrs.Length == 0)) - { - collector.EmitError(string.Empty, "--use-issue-number requires --prs or --issues to be specified."); + collector.EmitError(string.Empty, + "--use-issue-number is no longer supported. Changelog entries are always keyed by PR number. " + + "For items with no PR use 'changelog note' instead."); _ = collector.StartAsync(ctx); await collector.WaitForDrain(); await collector.StopAsync(ctx); @@ -529,7 +512,6 @@ async static (s, collector, state, ctx) => await s.CreateChangelogsFromRelease(c Output = resolvedOutput, Config = config?.FullName, UsePrNumber = usePrNumber, - UseIssueNumber = useIssueNumber, StripTitlePrefix = stripTitlePrefixResolved, ExtractReleaseNotes = extractReleaseNotes, ExtractIssues = extractIssues, diff --git a/tests/Elastic.Changelog.Tests/Changelogs/ChangelogConfigurationTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/ChangelogConfigurationTests.cs index f0b1af079f..131c22f61f 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/ChangelogConfigurationTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/ChangelogConfigurationTests.cs @@ -1359,24 +1359,36 @@ public async Task LoadChangelogConfiguration_WithRulesBundle_BothExcludeAndInclu Collector.Diagnostics.Should().Contain(d => d.Message.Contains("cannot have both 'exclude_products' and 'include_products'")); } - [Theory] - [InlineData("pr", FilenameStrategy.Pr)] - [InlineData("issue", FilenameStrategy.Issue)] - [InlineData("timestamp", FilenameStrategy.Timestamp)] - public async Task LoadChangelogConfiguration_Filename_ParsesStrategy(string yamlValue, FilenameStrategy expected) + [Fact] + public async Task LoadChangelogConfiguration_Filename_Pr_ParsesStrategy() { - var config = await LoadConfig( - $""" - filename: {yamlValue} - """); + var config = await LoadConfig("filename: pr"); config.Should().NotBeNull(); Collector.Errors.Should().Be(0); - config.Filename.Should().Be(expected); + config.Filename.Should().Be(FilenameStrategy.Pr); + } + + [Fact] + public async Task LoadChangelogConfiguration_Filename_Issue_ReturnsError() + { + var config = await LoadConfig("filename: issue"); + + config.Should().BeNull(); + Collector.Errors.Should().BeGreaterThan(0); + } + + [Fact] + public async Task LoadChangelogConfiguration_Filename_Timestamp_ReturnsError() + { + var config = await LoadConfig("filename: timestamp"); + + config.Should().BeNull(); + Collector.Errors.Should().BeGreaterThan(0); } [Fact] - public async Task LoadChangelogConfiguration_Filename_Missing_DefaultsToTimestamp() + public async Task LoadChangelogConfiguration_Filename_Missing_DefaultsToPr() { var config = await LoadConfig( """ @@ -1386,7 +1398,7 @@ public async Task LoadChangelogConfiguration_Filename_Missing_DefaultsToTimestam config.Should().NotBeNull(); Collector.Errors.Should().Be(0); - config.Filename.Should().Be(FilenameStrategy.Timestamp); + config.Filename.Should().Be(FilenameStrategy.Pr); } [Fact] diff --git a/tests/Elastic.Changelog.Tests/Changelogs/Create/BasicInputTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/Create/BasicInputTests.cs index 8e1edfac88..855824b96b 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/Create/BasicInputTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/Create/BasicInputTests.cs @@ -21,6 +21,7 @@ public async Task CreateChangelog_WithBasicInput_CreatesValidYamlFile() Type = "feature", Products = [new ProductArgument { Product = "elasticsearch", Target = "9.2.0", Lifecycle = "ga" }], Description = "This is a new search feature", + Prs = ["https://github.com/elastic/elasticsearch/pull/12345"], Output = CreateOutputDirectory() }; @@ -68,6 +69,7 @@ public async Task CreateChangelog_WithMultipleProducts_CreatesValidYaml() new ProductArgument { Product = "elasticsearch", Target = "9.2.0", Lifecycle = "ga" }, new ProductArgument { Product = "kibana", Target = "9.2.0", Lifecycle = "ga" } ], + Prs = ["https://github.com/elastic/elasticsearch/pull/12345"], Output = CreateOutputDirectory() }; @@ -112,6 +114,7 @@ public async Task CreateChangelog_WithBreakingChangeAndSubtype_CreatesValidYaml( Products = [new ProductArgument { Product = "elasticsearch", Target = "9.2.0" }], Impact = "API clients will need to update", Action = "Update your API client code", + Prs = ["https://github.com/elastic/elasticsearch/pull/12345"], Output = CreateOutputDirectory() }; diff --git a/tests/Elastic.Changelog.Tests/Changelogs/Create/FlagsAndFeaturesTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/Create/FlagsAndFeaturesTests.cs index 4c550a12e0..081a7775d4 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/Create/FlagsAndFeaturesTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/Create/FlagsAndFeaturesTests.cs @@ -21,6 +21,7 @@ public async Task CreateChangelog_WithHighlightFlag_CreatesValidYaml() Type = "feature", Products = [new ProductArgument { Product = "elasticsearch", Target = "9.2.0" }], Highlight = true, + Prs = ["https://github.com/elastic/elasticsearch/pull/12345"], Output = CreateOutputDirectory() }; @@ -58,6 +59,7 @@ public async Task CreateChangelog_WithFeatureId_CreatesValidYaml() Type = "feature", Products = [new ProductArgument { Product = "elasticsearch", Target = "9.2.0" }], FeatureId = "feature:new-search-api", + Prs = ["https://github.com/elastic/elasticsearch/pull/12345"], Output = CreateOutputDirectory() }; @@ -99,6 +101,7 @@ public async Task CreateChangelog_WithIssues_CreatesValidYaml() "https://github.com/elastic/elasticsearch/issues/123", "https://github.com/elastic/elasticsearch/issues/456" ], + Prs = ["https://github.com/elastic/elasticsearch/pull/12345"], Output = CreateOutputDirectory() }; diff --git a/tests/Elastic.Changelog.Tests/Changelogs/Create/PrIntegrationTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/Create/PrIntegrationTests.cs index 2c1760c00d..7c55fc5a94 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/Create/PrIntegrationTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/Create/PrIntegrationTests.cs @@ -220,9 +220,9 @@ public async Task CreateChangelog_WithMultiplePrsAndUsePrNumber_CreatesOneFilePe } [Fact] - public async Task CreateChangelog_WithUseIssueNumberAndBothIssuesAndPrs_UseIssueNumberForFilename() + public async Task CreateChangelog_WithBothIssuesAndPrs_UsesPrNumberForFilename() { - // When both --issues and --prs are specified, --use-issue-number should still determine the filename + // Filename is always derived from the PR number; issue-number naming was removed. var prInfo = new GitHubPrInfo { Title = "Release notes test", @@ -258,7 +258,6 @@ public async Task CreateChangelog_WithUseIssueNumberAndBothIssuesAndPrs_UseIssue Products = [new ProductArgument { Product = "kibana", Target = "9.2.0", Lifecycle = "ga" }], Config = configPath, Output = CreateOutputDirectory(), - UseIssueNumber = true, Title = "Release notes test", Type = "feature" }; @@ -272,7 +271,7 @@ public async Task CreateChangelog_WithUseIssueNumberAndBothIssuesAndPrs_UseIssue files.Should().HaveCount(1); var fileName = Path.GetFileName(files[0]); - fileName.Should().Be("233425.yaml", "the filename should use the issue number when UseIssueNumber is true, even with PRs present"); + fileName.Should().Be("250840.yaml", "the filename should use the PR number; issue-number naming is no longer supported"); } [Fact] diff --git a/tests/Elastic.Changelog.Tests/Changelogs/Create/TitleProcessingTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/Create/TitleProcessingTests.cs index aa4c36077a..e25be7667a 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/Create/TitleProcessingTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/Create/TitleProcessingTests.cs @@ -320,6 +320,7 @@ public async Task CreateChangelog_WithIssues_CreatesValidYaml() "https://github.com/elastic/elasticsearch/issues/123", "https://github.com/elastic/elasticsearch/issues/456" ], + Prs = ["https://github.com/elastic/elasticsearch/pull/12345"], Output = CreateOutputDirectory() }; diff --git a/tests/Elastic.Changelog.Tests/Changelogs/Create/ValidationTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/Create/ValidationTests.cs index f454788cb8..7f7021406b 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/Create/ValidationTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/Create/ValidationTests.cs @@ -169,6 +169,7 @@ public async Task CreateChangelog_WithRepoMatchingKnownProduct_InfersProduct() Type = "feature", Products = [], Repo = "kibana", + Prs = ["https://github.com/elastic/elasticsearch/pull/12345"], Output = outputDir }; @@ -248,6 +249,7 @@ public async Task CreateChangelog_WithValidProductInAddBlockers_Succeeds() Title = "Test", Type = "feature", Products = [new ProductArgument { Product = "elasticsearch", Target = "9.2.0" }], + Prs = ["https://github.com/elastic/elasticsearch/pull/12345"], Config = configPath, Output = CreateOutputDirectory() }; diff --git a/tests/Elastic.Changelog.Tests/Creation/ChangelogCreationServiceTests.cs b/tests/Elastic.Changelog.Tests/Creation/ChangelogCreationServiceTests.cs index 043d536f0f..24ecf84144 100644 --- a/tests/Elastic.Changelog.Tests/Creation/ChangelogCreationServiceTests.cs +++ b/tests/Elastic.Changelog.Tests/Creation/ChangelogCreationServiceTests.cs @@ -240,6 +240,7 @@ public async Task CreateChangelog_OutputDoesNotContainBom() Title = "Test BOM handling", Type = "feature", Products = [new ProductArgument { Product = "elasticsearch", Target = "9.1.0", Lifecycle = "ga" }], + Prs = ["https://github.com/elastic/elasticsearch/pull/12345"], Config = Path.Join(Paths.WorkingDirectoryRoot.FullName, "config", "changelog.yml"), Output = tempOutput, Concise = true diff --git a/tests/Elastic.Changelog.Tests/Creation/FilenameStrategyTests.cs b/tests/Elastic.Changelog.Tests/Creation/FilenameStrategyTests.cs index 6460a917ab..3d9a841c9b 100644 --- a/tests/Elastic.Changelog.Tests/Creation/FilenameStrategyTests.cs +++ b/tests/Elastic.Changelog.Tests/Creation/FilenameStrategyTests.cs @@ -14,74 +14,35 @@ private static CreateChangelogArguments DefaultInput() => new() { Products = [] }; [Fact] - public void ApplyConfigDefaults_FilenamePr_SetsUsePrNumber() + public void ApplyConfigDefaults_AlwaysSetsPrNumberTrue() { - var config = ChangelogConfiguration.Default with { Filename = FilenameStrategy.Pr }; - var input = DefaultInput(); - - var result = ChangelogCreationService.ApplyConfigDefaults(input, config); - - result.UsePrNumber.Should().BeTrue(); - result.UseIssueNumber.Should().BeFalse(); - } - - [Fact] - public void ApplyConfigDefaults_FilenameIssue_SetsUseIssueNumber() - { - var config = ChangelogConfiguration.Default with { Filename = FilenameStrategy.Issue }; + var config = ChangelogConfiguration.Default; var input = DefaultInput(); var result = ChangelogCreationService.ApplyConfigDefaults(input, config); - result.UsePrNumber.Should().BeFalse(); - result.UseIssueNumber.Should().BeTrue(); + result.UsePrNumber.Should().BeTrue("filename strategy is always Pr"); } [Fact] - public void ApplyConfigDefaults_FilenameTimestamp_NeitherFlagSet() + public void ApplyConfigDefaults_DefaultConfig_UsesPr() { - var config = ChangelogConfiguration.Default with { Filename = FilenameStrategy.Timestamp }; + var config = ChangelogConfiguration.Default; var input = DefaultInput(); var result = ChangelogCreationService.ApplyConfigDefaults(input, config); - result.UsePrNumber.Should().BeFalse(); - result.UseIssueNumber.Should().BeFalse(); + result.UsePrNumber.Should().BeTrue("default FilenameStrategy is Pr"); } [Fact] - public void ApplyConfigDefaults_CLIUsePrNumber_OverridesConfigIssue() + public void ApplyConfigDefaults_CLIUsePrNumber_RemainsTrue() { - var config = ChangelogConfiguration.Default with { Filename = FilenameStrategy.Issue }; + var config = ChangelogConfiguration.Default; var input = DefaultInput() with { UsePrNumber = true }; var result = ChangelogCreationService.ApplyConfigDefaults(input, config); result.UsePrNumber.Should().BeTrue(); - result.UseIssueNumber.Should().BeFalse(); - } - - [Fact] - public void ApplyConfigDefaults_CLIUseIssueNumber_OverridesConfigPr() - { - var config = ChangelogConfiguration.Default with { Filename = FilenameStrategy.Pr }; - var input = DefaultInput() with { UseIssueNumber = true }; - - var result = ChangelogCreationService.ApplyConfigDefaults(input, config); - - result.UsePrNumber.Should().BeFalse(); - result.UseIssueNumber.Should().BeTrue(); - } - - [Fact] - public void ApplyConfigDefaults_DefaultConfig_UsesTimestamp() - { - var config = ChangelogConfiguration.Default; - var input = DefaultInput(); - - var result = ChangelogCreationService.ApplyConfigDefaults(input, config); - - result.UsePrNumber.Should().BeFalse("default FilenameStrategy is Timestamp"); - result.UseIssueNumber.Should().BeFalse("default FilenameStrategy is Timestamp"); } } From d83f2d63cad2508a3e43423a36fd2b4c6692d3b7 Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Tue, 25 Aug 2026 12:58:07 +0200 Subject: [PATCH 2/4] fix(scrubber): retire pool registry pass-through; drop stale events silently MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pool-tree index changelog/{org}/{repo}/{branch}/registry.json has not been written by any client since #3760. Events for that key shape are now dropped with a debug log rather than mirrored verbatim to the public bucket. A stale create event from an old CLI version is harmless; no delete propagation is needed either, since nothing reads these keys. Bundle registry events (bundle/{product}/registry.json) continue to schedule a group reconcile as before — they are reconciler-owned and this change does not affect them. Co-Authored-By: Claude Sonnet 4.6 --- .../Scrubbing/ScrubberProcessor.cs | 12 ++++++------ .../Scrubbing/ScrubberProcessorTests.cs | 19 +++++++++---------- 2 files changed, 15 insertions(+), 16 deletions(-) diff --git a/src/services/Elastic.Changelog/Scrubbing/ScrubberProcessor.cs b/src/services/Elastic.Changelog/Scrubbing/ScrubberProcessor.cs index bef62e55b7..64132e18be 100644 --- a/src/services/Elastic.Changelog/Scrubbing/ScrubberProcessor.cs +++ b/src/services/Elastic.Changelog/Scrubbing/ScrubberProcessor.cs @@ -158,15 +158,15 @@ private void Classify( if (!hasScope) return; - // The two trees part ways here. Bundle manifests are reconciler-owned: the event only - // schedules a group reconcile, so client-authored JSON never reaches the public bucket - // for the tree consumers enumerate. Pool manifests stay client-authored pass-through — - // `changelog bundle` still enumerates a pool through its manifest today, and 404-probing - // only works once entries are guaranteed one-per-PR — until Phase 3 retires them. + // Bundle manifests are reconciler-owned: the event schedules a group reconcile so + // client-authored JSON never reaches the public bucket directly. Pool registry keys + // (changelog/{org}/{repo}/{branch}/registry.json) are no longer written by any client + // — the pool index was retired in #3760. Drop them with a debug log; a stale event + // from an old client is harmless and does not need to copy anything. if (scope!.Kind == ChangelogScopeKind.Bundle) AddGroup(groupWork, scope, messageId); else - AddObject(objectWork, key, sourceBucket, messageId, passThrough: true); + _logger.LogDebug("Ignoring retired pool registry key: {Key}", key); return; } diff --git a/tests/Elastic.Changelog.Tests/Scrubbing/ScrubberProcessorTests.cs b/tests/Elastic.Changelog.Tests/Scrubbing/ScrubberProcessorTests.cs index 7ae9d72f49..3daaa01097 100644 --- a/tests/Elastic.Changelog.Tests/Scrubbing/ScrubberProcessorTests.cs +++ b/tests/Elastic.Changelog.Tests/Scrubbing/ScrubberProcessorTests.cs @@ -122,11 +122,10 @@ public async Task Process_BundleRegistryKeyEvents_NeverCopyOrDelete_OnlyTriggerA } [Fact] - public async Task Process_PoolRegistryKeyEvents_ArePassedThroughVerbatim() + public async Task Process_PoolRegistryKeyEvents_AreIgnored() { - // Pool manifests stay client-authored until Phase 3: `changelog bundle` still enumerates a - // pool through its manifest, so the private copy is mirrored verbatim — never scrubbed, - // never reconciled. + // Pool registry keys (changelog/{org}/{repo}/{branch}/registry.json) are retired — no client + // writes them since #3760. A stale event from an old client is silently dropped. const string poolRegistry = "changelog/elastic/kibana/main/registry.json"; const string content = /*lang=json,strict*/ """{"schema_version":1,"bundles":[{"file":"100.yaml"}]}"""; _ = _s3.Seed(PrivateBucket, poolRegistry, content); @@ -134,23 +133,23 @@ public async Task Process_PoolRegistryKeyEvents_ArePassedThroughVerbatim() var failed = await _processor.ProcessAsync([Message("ObjectCreated:Put", poolRegistry)], Ctx); failed.Should().BeEmpty(); - _s3.ContentOf(PublicBucket, poolRegistry).Should().Be(content, "pass-through must not transform the manifest"); + _s3.Exists(PublicBucket, poolRegistry).Should().BeFalse("retired pool registry keys are not mirrored"); _metrics.GroupReconciles.Should().Be(0, "pool manifests are not reconciled"); - _s3.Puts.Single(p => p.Key == poolRegistry).ContentType.Should().Be("application/json"); + _s3.Puts.Should().BeEmpty("no S3 writes for a retired pool registry event"); } [Fact] - public async Task Process_PoolRegistryKeyEvents_WithPrivateGone_DeleteThePublicCopy() + public async Task Process_PoolRegistryKeyDeleteEvents_AreAlsoIgnored() { - // State decides for pass-through keys too: Phase 3's private-manifest cleanup deletes will - // propagate and remove the public pool manifests with them. + // Delete events for the retired pool registry are dropped the same way as creates — + // the key is no longer managed, so no public-bucket delete is needed. const string poolRegistry = "changelog/elastic/kibana/main/registry.json"; _ = _s3.Seed(PublicBucket, poolRegistry, "{}"); var failed = await _processor.ProcessAsync([Message("ObjectRemoved:Delete", poolRegistry)], Ctx); failed.Should().BeEmpty(); - _s3.Exists(PublicBucket, poolRegistry).Should().BeFalse(); + _s3.Exists(PublicBucket, poolRegistry).Should().BeTrue("the event was ignored; no delete happened"); } [Fact] From 6924884dd5c50a4bf07b7368409f209b7838fbb2 Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Tue, 25 Aug 2026 13:12:13 +0200 Subject: [PATCH 3/4] fix(changelog): --files uses keyed GET; --all/--input-products error on CDN Add FetchNamedAsync to CdnChangelogEntryFetcher for direct per-file GETs without consulting the pool registry. A 404 is authoritative (not retried), while 5xx/transport errors use the normal retry budget. Switch the --files CDN path in ChangelogBundlingService to FetchNamedAsync, fixing a regression where a --files run could not see entries omitted from the stale pool registry. Add a CDN guard that errors --all and --input-products with a message pointing at --force-local, since those filters require pool enumeration which no longer exists. Update tests: CDN sourcing pool-selection tests now use --prs (registry still fetched for that path); --files tests assert the direct entry URL rather than the registry; MonthlyProfile fixture converted to use_local_changelogs since product-filter CDN mode is now correctly blocked. Co-Authored-By: Claude Sonnet 4.6 --- .../ReleaseNotes/CdnChangelogEntryFetcher.cs | 108 ++++++++++++++++++ .../Bundling/ChangelogBundlingService.cs | 74 +++++++++++- .../Creation/ChangelogCreationService.cs | 6 +- .../Changelogs/BundleCdnSourcingTests.cs | 43 ++++++- .../Changelogs/BundleFilesFilterTests.cs | 11 +- .../Changelogs/CloudProfileFixtureTests.cs | 19 ++- 6 files changed, 238 insertions(+), 23 deletions(-) diff --git a/src/Elastic.Documentation.Configuration/ReleaseNotes/CdnChangelogEntryFetcher.cs b/src/Elastic.Documentation.Configuration/ReleaseNotes/CdnChangelogEntryFetcher.cs index c5eb895480..52b3d01093 100644 --- a/src/Elastic.Documentation.Configuration/ReleaseNotes/CdnChangelogEntryFetcher.cs +++ b/src/Elastic.Documentation.Configuration/ReleaseNotes/CdnChangelogEntryFetcher.cs @@ -178,6 +178,114 @@ public async Task> FetchAsync( return entries; } + /// + /// Fetches a named set of changelog entries directly from the CDN without consulting the pool registry. + /// Each entry is fetched by its key ({base}/changelog/{org}/{repo}/{branch}/{fileName}); a 404 + /// is a hard error (the caller explicitly requested the entry), while 5xx / transport errors use the + /// normal retry budget. Returns null after emitting an error when any entry cannot be fetched. + /// + public async Task?> FetchNamedAsync( + Uri baseUri, + string org, + string repo, + string branch, + IReadOnlyList fileNames, + Action emitError, + Cancel ctx) + { + var poolLabel = $"{org}/{repo}/{branch}"; + + if (!ChangelogKeys.IsValidOrg(org) || !ChangelogKeys.IsValidRepo(repo) || !ChangelogKeys.IsValidBranch(branch)) + { + emitError( + $"Invalid changelog pool '{poolLabel}': the org, repo, and each '/'-delimited branch segment must be non-empty ASCII letters, digits, '.', '_' or '-' (org allows only letters, digits and '-') and must not be '.' or '..'."); + return null; + } + + var poolSegments = ChangelogKeys.PoolSegments(org, repo, branch); + var entries = new List(fileNames.Count); + var hasError = false; + + foreach (var fileName in fileNames) + { + ctx.ThrowIfCancellationRequested(); + + if (!ChangelogKeys.IsSafeFileName(fileName)) + { + emitError($"Requested changelog entry '{fileName}' is not a valid file name for pool '{poolLabel}'."); + hasError = true; + continue; + } + + var entryUri = CombineSegments(baseUri, [.. poolSegments, fileName]); + var (fetched, content, lastError) = await TryFetchNamedEntryAsync(entryUri, fileName, poolLabel, ctx).ConfigureAwait(false); + if (fetched) + { + entries.Add(new CdnChangelogEntry(fileName, content)); + continue; + } + + // Explicit path-list request: a miss is a pipeline error (wrong name, not uploaded, or renamed). + emitError( + $"Changelog entry '{fileName}' for '{poolLabel}' could not be fetched from {entryUri}: {lastError}. " + + "Ensure the entry was uploaded (changelog upload), or pass --force-local / --directory to bundle local files instead."); + hasError = true; + } + + return hasError ? null : entries; + } + + /// + /// Fetches a single explicitly-requested entry. A 404 is surfaced immediately (not retried) because the + /// caller knows the entry should exist; 5xx and transport errors use the normal retry budget. + /// + private async Task<(bool Fetched, string Content, string? LastError)> TryFetchNamedEntryAsync(Uri uri, string fileName, string poolLabel, Cancel ctx) + { + string? lastError = null; + + for (var attempt = 1; attempt <= _maxAttempts; attempt++) + { + ctx.ThrowIfCancellationRequested(); + try + { + var (notFound, content) = await FetchTextOrNotFoundAsync(uri, attempt, ctx).ConfigureAwait(false); + if (notFound) + return (false, string.Empty, "404 Not Found — entry does not exist in the pool"); + if (attempt > 1) + _logger.LogInformation("Fetched changelog entry '{File}' for {Pool} on attempt {Attempt}/{Max}", fileName, poolLabel, attempt, _maxAttempts); + return (true, content, null); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + lastError = ex.Message; + if (attempt >= _maxAttempts) + break; + + var delay = RetryDelay(attempt); + _logger.LogDebug( + "Changelog entry '{File}' for {Pool} not yet available (attempt {Attempt}/{Max}: {Error}); retrying in {Delay}", + fileName, poolLabel, attempt, _maxAttempts, ex.Message, delay); + await _sleep(delay, ctx).ConfigureAwait(false); + } + } + + return (false, string.Empty, lastError); + } + + /// Returns (notFound: true) for a 404; throws for other non-success status codes. + private async Task<(bool NotFound, string Content)> FetchTextOrNotFoundAsync(Uri uri, int attempt, Cancel ctx) + { + var requestUri = attempt > 1 ? WithCacheBuster(uri) : uri; + using var request = new HttpRequestMessage(HttpMethod.Get, requestUri); + if (attempt > 1) + _ = request.Headers.TryAddWithoutValidation("Cache-Control", "no-cache"); + using var response = await _httpClient.SendAsync(request, ctx).ConfigureAwait(false); + if (response.StatusCode == HttpStatusCode.NotFound) + return (true, string.Empty); + _ = response.EnsureSuccessStatusCode(); + return (false, await response.Content.ReadAsStringAsync(ctx).ConfigureAwait(false)); + } + /// /// Fetches a single entry, retrying transient failures (most importantly a not-yet-propagated 404) /// up to times with exponential backoff. Retry requests are cache-busted diff --git a/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs b/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs index c9b9e65bda..832e95b1b5 100644 --- a/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs +++ b/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs @@ -289,6 +289,17 @@ public async Task BundleChangelogs(IDiagnosticsCollector collector, Bundle if (!ValidateInput(collector, input, requireDirectoryExists: !useCdn)) return false; + // --all and --input-products require reading every entry body; that is only possible locally. + // On the CDN path entries are probed by key, so there is nothing to enumerate without a PR list. + if (useCdn && (input.All || input.InputProducts is { Count: > 0 })) + { + var flag = input.All ? "--all" : "--input-products"; + collector.EmitError(string.Empty, + $"{flag} is not supported when sourcing changelog entries from the CDN, because entries are fetched by key (one per PR) and there is no pool enumeration. " + + "Pass --force-local or --directory to bundle from a local checkout instead."); + return false; + } + if (!ValidatePlaceholderUsage(collector, input)) return false; @@ -357,13 +368,10 @@ public async Task BundleChangelogs(IDiagnosticsCollector collector, Bundle } else if (useCdn) { - var contents = await FetchCdnEntriesAsync(collector, authoringOwner, authoringRepo, authoringBranch, ctx); - if (contents == null) - return false; if (requestedEntryNames is not null) { - var poolLabel = $"{authoringOwner}/{authoringRepo}/{authoringBranch}"; - var selected = SelectRequestedCdnEntries(collector, contents, requestedEntryNames, poolLabel); + // --files on the CDN path: fetch each entry directly by key; no registry needed. + var selected = await FetchCdnNamedEntriesAsync(collector, authoringOwner, authoringRepo, authoringBranch, requestedEntryNames, ctx); if (selected == null) return false; _logger.LogInformation("Matching {Count} explicitly selected changelog entries from the CDN", selected.Count); @@ -371,7 +379,14 @@ public async Task BundleChangelogs(IDiagnosticsCollector collector, Bundle matchResult = entryMatcher.MatchChangelogContents(collector, selected, filesCriteria, ctx); } else + { + // --prs / --issues on the CDN path: still uses the pool registry for now (Step 9 will + // switch this to per-PR probing once canonical keys and markers are in place). + var contents = await FetchCdnEntriesAsync(collector, authoringOwner, authoringRepo, authoringBranch, ctx); + if (contents == null) + return false; matchResult = entryMatcher.MatchChangelogContents(collector, contents, filterCriteria, ctx); + } } else { @@ -1096,6 +1111,55 @@ private BundleChangelogsArguments ApplyConfigDefaults(BundleChangelogsArguments return null; } + /// + /// Fetches a named list of changelog entries directly from the CDN without consulting the pool registry. + /// Used for the --files CDN path. Returns null after emitting an error on any failure. + /// + private async Task?> FetchCdnNamedEntriesAsync( + IDiagnosticsCollector collector, + string? org, + string? repo, + string? branch, + IReadOnlyList fileNames, + Cancel ctx) + { + if (string.IsNullOrWhiteSpace(repo)) + { + collector.EmitError(string.Empty, + "Sourcing changelog entries from the CDN requires a resolvable authoring repository. " + + "Set bundle.repo in changelog.yml (or pass --repo), or pass --force-local / --directory to bundle local files."); + return null; + } + + var resolvedOrg = string.IsNullOrWhiteSpace(org) ? DefaultOwner : org; + var resolvedBranch = string.IsNullOrWhiteSpace(branch) ? DefaultBranch : branch; + + var baseUri = ChangelogCdn.ResolveBaseUri(); + if (baseUri is null) + { + collector.EmitError(string.Empty, + $"No valid changelog CDN base URL is configured. Set the {ChangelogCdn.BaseUrlEnvironmentVariable} environment variable to an absolute http(s) URL."); + return null; + } + + var entries = await _entryFetcher.FetchNamedAsync( + baseUri, + resolvedOrg, + repo, + resolvedBranch, + fileNames, + msg => collector.EmitError(string.Empty, msg), + ctx); + + if (entries == null) + return null; + + _logger.LogInformation("Fetched {Count} named changelog entry(ies) for {Pool} from CDN", + entries.Count, $"{resolvedOrg}/{repo}/{resolvedBranch}"); + + return entries.Select(e => (e.FileName, e.Content)).ToList(); + } + /// Downloads the authoring // pool's changelog entries from the CDN (changelog/{org}/{repo}/{branch}/...); returns null after emitting an error on any fatal fetch failure. private async Task?> FetchCdnEntriesAsync( IDiagnosticsCollector collector, diff --git a/src/services/Elastic.Changelog/Creation/ChangelogCreationService.cs b/src/services/Elastic.Changelog/Creation/ChangelogCreationService.cs index 3914293fe0..8333899d2a 100644 --- a/src/services/Elastic.Changelog/Creation/ChangelogCreationService.cs +++ b/src/services/Elastic.Changelog/Creation/ChangelogCreationService.cs @@ -146,17 +146,15 @@ public async Task CreateChangelog(IDiagnosticsCollector collector, CreateC } } - internal static CreateChangelogArguments ApplyConfigDefaults(CreateChangelogArguments input, ChangelogConfiguration config) - { + internal static CreateChangelogArguments ApplyConfigDefaults(CreateChangelogArguments input, ChangelogConfiguration config) => // Filename strategy is always Pr now; UsePrNumber is kept for backward compat but is effectively always true. - return input with + input with { ExtractReleaseNotes = input.ExtractReleaseNotes ?? config.Extract.ReleaseNotes, ExtractIssues = input.ExtractIssues ?? config.Extract.Issues, StripTitlePrefix = input.StripTitlePrefix ?? config.Extract.StripTitlePrefix, UsePrNumber = true }; - } /// /// Infers products from configuration defaults or repository name. diff --git a/tests/Elastic.Changelog.Tests/Changelogs/BundleCdnSourcingTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/BundleCdnSourcingTests.cs index 63b770eae0..1c7b77633b 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/BundleCdnSourcingTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/BundleCdnSourcingTests.cs @@ -79,7 +79,7 @@ public async Task OptionMode_RepoResolvable_SourcesAllEntriesFromRepoPoolOnCdn() var input = new BundleChangelogsArguments { - InputProducts = [new ProductArgument { Product = "elasticsearch", Target = "*", Lifecycle = "*" }], + Prs = ["https://github.com/elastic/elasticsearch/pull/100", "https://github.com/elastic/elasticsearch/pull/999"], Output = output, Repo = "elasticsearch" }; @@ -108,7 +108,7 @@ public async Task OptionMode_OwnerAndBranchOverride_SourcesFromThatPoolOnCdn() var input = new BundleChangelogsArguments { - InputProducts = [new ProductArgument { Product = "elasticsearch", Target = "*", Lifecycle = "*" }], + Prs = ["https://github.com/elastic/elasticsearch/pull/100", "https://github.com/elastic/elasticsearch/pull/999"], Output = output, Owner = "acme-corp", Repo = "elasticsearch", @@ -133,7 +133,7 @@ public async Task OptionMode_OwnerFromCombinedRepo_SourcesFromThatPool() var input = new BundleChangelogsArguments { - InputProducts = [new ProductArgument { Product = "elasticsearch", Target = "*", Lifecycle = "*" }], + Prs = ["https://github.com/elastic/elasticsearch/pull/100", "https://github.com/elastic/elasticsearch/pull/999"], Output = output, Repo = "acme-corp/widget" }; @@ -221,6 +221,39 @@ await FileSystem.File.WriteAllTextAsync( bundle.Should().Contain("name: 1-local.yaml"); } + [Fact] + public async Task CdnAll_ReturnsError() + { + var service = new ChangelogBundlingService(LoggerFactory, FileSystem, null, null, Fetcher()); + + var input = new BundleChangelogsArguments { All = true, Output = OutputPath(), Repo = "elasticsearch" }; + + 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("--all") && d.Message.Contains("--force-local")); + } + + [Fact] + public async Task CdnInputProducts_ReturnsError() + { + var service = new ChangelogBundlingService(LoggerFactory, FileSystem, null, null, Fetcher()); + + var input = new BundleChangelogsArguments + { + InputProducts = [new ProductArgument { Product = "elasticsearch", Target = "9.3.0", Lifecycle = "ga" }], + Output = OutputPath(), + Repo = "elasticsearch" + }; + + 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("--input-products") && d.Message.Contains("--force-local")); + } + [Fact] public async Task RegistryFailure_FailsBundle() { @@ -230,7 +263,7 @@ public async Task RegistryFailure_FailsBundle() var input = new BundleChangelogsArguments { - InputProducts = [new ProductArgument { Product = "elasticsearch", Target = "*", Lifecycle = "*" }], + Prs = ["https://github.com/elastic/elasticsearch/pull/100"], Output = OutputPath(), Repo = "elasticsearch" }; @@ -260,7 +293,7 @@ public async Task EntryMissingAfterRetries_FailsBundle() var input = new BundleChangelogsArguments { - InputProducts = [new ProductArgument { Product = "elasticsearch", Target = "*", Lifecycle = "*" }], + Prs = ["https://github.com/elastic/elasticsearch/pull/100", "https://github.com/elastic/elasticsearch/pull/999"], Output = OutputPath(), Repo = "elasticsearch" }; diff --git a/tests/Elastic.Changelog.Tests/Changelogs/BundleFilesFilterTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/BundleFilesFilterTests.cs index 7ba1ab2e2e..b39b86bc3d 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/BundleFilesFilterTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/BundleFilesFilterTests.cs @@ -321,7 +321,9 @@ public async Task Bundle_WithFiles_RepoResolves_MatchesCdnPoolByFileName() 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"); + // --files uses FetchNamedAsync: direct GET per file, no registry fetch + handler.RequestedPaths.Should().Contain("/changelog/elastic/elasticsearch/main/keep.yaml"); + handler.RequestedPaths.Should().NotContain("/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"); @@ -343,8 +345,9 @@ public async Task Bundle_WithFiles_CdnPoolMissingRequestedName_FailsBundle() var result = await service.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); result.Should().BeFalse(); + // --files uses FetchNamedAsync: a 404 means "entry does not exist in the pool" Collector.Diagnostics.Should().Contain(d => - d.Severity == Severity.Error && d.Message.Contains("not found in the CDN pool") && d.Message.Contains("never-uploaded.yaml")); + d.Severity == Severity.Error && d.Message.Contains("never-uploaded.yaml") && d.Message.Contains("pool")); } [Fact] @@ -384,7 +387,9 @@ public async Task Bundle_WithProfile_PathListFile_RepoResolves_SourcesFromCdn() 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"); + // --files (via profile report) uses FetchNamedAsync: direct GET per file, no registry fetch + handler.RequestedPaths.Should().Contain("/changelog/elastic/elasticsearch/main/keep.yaml"); + handler.RequestedPaths.Should().NotContain("/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"); diff --git a/tests/Elastic.Changelog.Tests/Changelogs/CloudProfileFixtureTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/CloudProfileFixtureTests.cs index 32294e20b7..d95349afaf 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/CloudProfileFixtureTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/CloudProfileFixtureTests.cs @@ -97,6 +97,13 @@ public async Task MonthlyProfile_SourcesFromRepoPool_ProducesSoundBundle() var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(outputDir); + // Write entries to a local changelog directory (use_local_changelogs: true forces local sourcing). + var changelogDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "changelog"); + FileSystem.Directory.CreateDirectory(changelogDir); + await FileSystem.File.WriteAllTextAsync(FileSystem.Path.Join(changelogDir, "1-feature.yaml"), FeatureEntry, TestContext.Current.CancellationToken); + await FileSystem.File.WriteAllTextAsync(FileSystem.Path.Join(changelogDir, "2-docs.yaml"), DocsEntry, TestContext.Current.CancellationToken); + await FileSystem.File.WriteAllTextAsync(FileSystem.Path.Join(changelogDir, "3-other.yaml"), OtherProductEntry, TestContext.Current.CancellationToken); + // language=yaml var configContent = """ @@ -114,10 +121,12 @@ public async Task MonthlyProfile_SourcesFromRepoPool_ProducesSoundBundle() bundle: exclude_types: "docs" bundle: - output_directory: PLACEHOLDER + output_directory: OUTPUT_DIR + directory: CHANGELOG_DIR repo: widget owner: elastic release_dates: false + use_local_changelogs: true link_allow_repos: - elastic/elasticsearch - elastic/kibana @@ -126,7 +135,7 @@ public async Task MonthlyProfile_SourcesFromRepoPool_ProducesSoundBundle() products: "cloud-hosted {version}-* *" output: "widget-{version}.yaml" output_products: "cloud-hosted {version}" - """.Replace("PLACEHOLDER", outputDir); + """.Replace("OUTPUT_DIR", outputDir).Replace("CHANGELOG_DIR", changelogDir); var configPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "changelog.yml"); FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); @@ -147,10 +156,8 @@ public async Task MonthlyProfile_SourcesFromRepoPool_ProducesSoundBundle() result.Should().BeTrue($"Errors: {string.Join("; ", Collector.Diagnostics.Where(d => d.Severity == Severity.Error).Select(d => d.Message))}"); Collector.Errors.Should().Be(0); - // Entries are sourced once from the authoring pool (changelog/{org}/{repo}/{branch}/...), not from - // any product-scoped path. Owner comes from bundle.owner; branch defaults to "main". - handler.RequestedPaths.Should().Contain($"/changelog/elastic/{AuthoringRepo}/main/registry.json"); - handler.RequestedPaths.Should().NotContain(p => p.Contains("/cloud-hosted/changelog/", StringComparison.Ordinal)); + // use_local_changelogs: true forces local sourcing; the CDN must not be touched. + handler.RequestedPaths.Should().BeEmpty("use_local_changelogs must not reach the CDN"); var outputFiles = FileSystem.Directory.GetFiles(outputDir, "*.yaml"); outputFiles.Should().ContainSingle("the monthly profile writes a single bundle file"); From b4afaf5a552233a82b095655a422b4be836df6d8 Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Tue, 25 Aug 2026 14:05:39 +0200 Subject: [PATCH 4/4] fix: address AI reviewer comments on --files keyed GET PR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ChangelogFileWriter: GenerateFilename now returns null? on error and WriteChangelogAsync short-circuits before writing when no PR is derivable. Previously the placeholder 'changelog.yaml' was written unconditionally. - CdnChangelogEntryFetcher.TryFetchNamedEntryAsync: permanent client errors (4xx other than 404) no longer consume the retry budget — they fail fast immediately. Only 5xx / transport errors retry as before. - Update PrFetchFailureTests: issue-only+StrictFetch flow now returns false (no file written) because filename derivation requires a PR number. Co-Authored-By: Claude Sonnet 4.6 --- .../ReleaseNotes/CdnChangelogEntryFetcher.cs | 5 +++++ .../Elastic.Changelog/Creation/ChangelogFileWriter.cs | 10 ++++++---- .../Changelogs/Create/PrFetchFailureTests.cs | 7 ++++--- 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/src/Elastic.Documentation.Configuration/ReleaseNotes/CdnChangelogEntryFetcher.cs b/src/Elastic.Documentation.Configuration/ReleaseNotes/CdnChangelogEntryFetcher.cs index 52b3d01093..f511292fdd 100644 --- a/src/Elastic.Documentation.Configuration/ReleaseNotes/CdnChangelogEntryFetcher.cs +++ b/src/Elastic.Documentation.Configuration/ReleaseNotes/CdnChangelogEntryFetcher.cs @@ -238,6 +238,7 @@ public async Task> FetchAsync( /// /// Fetches a single explicitly-requested entry. A 404 is surfaced immediately (not retried) because the /// caller knows the entry should exist; 5xx and transport errors use the normal retry budget. + /// Permanent client errors (4xx other than 404) also fail immediately without retry. /// private async Task<(bool Fetched, string Content, string? LastError)> TryFetchNamedEntryAsync(Uri uri, string fileName, string poolLabel, Cancel ctx) { @@ -257,6 +258,10 @@ public async Task> FetchAsync( } catch (Exception ex) when (ex is not OperationCanceledException) { + // Permanent client errors (4xx — 404 is handled above as notFound) must not be retried. + if (ex is HttpRequestException { StatusCode: >= HttpStatusCode.BadRequest and < HttpStatusCode.InternalServerError }) + return (false, string.Empty, ex.Message); + lastError = ex.Message; if (attempt >= _maxAttempts) break; diff --git a/src/services/Elastic.Changelog/Creation/ChangelogFileWriter.cs b/src/services/Elastic.Changelog/Creation/ChangelogFileWriter.cs index 05662b1275..c168fbee42 100644 --- a/src/services/Elastic.Changelog/Creation/ChangelogFileWriter.cs +++ b/src/services/Elastic.Changelog/Creation/ChangelogFileWriter.cs @@ -47,8 +47,11 @@ public async Task WriteChangelogAsync( if (!fileSystem.Directory.Exists(outputDir)) _ = fileSystem.Directory.CreateDirectory(outputDir); - // Generate filename + // Generate filename — returns null and emits an error when no PR number is derivable. var filename = GenerateFilename(collector, input); + if (filename == null) + return false; + var filePath = fileSystem.Path.Join(outputDir, filename); // Write UTF-8 text without BOM using explicit encoding instance. @@ -62,7 +65,7 @@ public async Task WriteChangelogAsync( /// Maximum filename length before extension to avoid filesystem path-too-long errors. private const int MaxFilenameLength = 200; - private string GenerateFilename(IDiagnosticsCollector collector, CreateChangelogArguments input) + private string? GenerateFilename(IDiagnosticsCollector collector, CreateChangelogArguments input) { if (input.Prs is { Length: > 0 }) { @@ -88,8 +91,7 @@ private string GenerateFilename(IDiagnosticsCollector collector, CreateChangelog "Could not derive a PR number from the provided --prs values. " + "Changelog entries must be anchored to a PR number. " + "For items with no PR use 'changelog note' instead."); - // Return a placeholder; the caller checks the collector for errors. - return "changelog.yaml"; + return null; } private static ChangelogEntry BuildChangelogData(CreateChangelogArguments input) diff --git a/tests/Elastic.Changelog.Tests/Changelogs/Create/PrFetchFailureTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/Create/PrFetchFailureTests.cs index 87f3d7c506..61d30d49c3 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/Create/PrFetchFailureTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/Create/PrFetchFailureTests.cs @@ -243,9 +243,10 @@ public async Task CreateChangelog_WithMultipleIssuesFetchFailsAndStrictFetch_Emi // Act var result = await service.CreateChangelog(Collector, input, TestContext.Current.CancellationToken); - // Assert: mirrors the PR path — under --strict-fetch the bulk fetch failure escalates to an error - // (non-zero exit), but the best-effort files are still written so they can be inspected. - result.Should().BeTrue(); + // Assert: under --strict-fetch the bulk fetch failure escalates to an error. + // No files are written because filename derivation requires a PR number; + // the caller must use 'changelog note' for issue-only entries. + result.Should().BeFalse(); Collector.Errors.Should().BeGreaterThan(0); Collector.Diagnostics.Should().Contain(d => d.Severity == Severity.Error &&