diff --git a/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs b/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs index 020bf56f4..1e67a46e8 100644 --- a/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs +++ b/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs @@ -199,8 +199,8 @@ private async Task WriteBundleFileAsync(Bundle bundledData, string outputPath, C _logger.LogInformation("Output file already exists, using unique filename: {OutputPath}", outputPath); } - // Write bundled file - await _fileSystem.File.WriteAllTextAsync(outputPath, bundledYaml, ctx); + // Write bundled file with explicit UTF-8 encoding to ensure proper character handling + await _fileSystem.File.WriteAllTextAsync(outputPath, bundledYaml, Encoding.UTF8, ctx); _logger.LogInformation("Created bundled changelog: {OutputPath}", outputPath); } diff --git a/src/services/Elastic.Changelog/Creation/ChangelogFileWriter.cs b/src/services/Elastic.Changelog/Creation/ChangelogFileWriter.cs index 5c29b4efb..1102e71d5 100644 --- a/src/services/Elastic.Changelog/Creation/ChangelogFileWriter.cs +++ b/src/services/Elastic.Changelog/Creation/ChangelogFileWriter.cs @@ -3,6 +3,7 @@ // See the LICENSE file in the project root for more information using System.IO.Abstractions; +using System.Text; using Elastic.Changelog.Configuration; using Elastic.Changelog.Serialization; using Elastic.Documentation; @@ -43,8 +44,8 @@ public async Task WriteChangelogAsync( var filename = GenerateFilename(collector, input, prUrl); var filePath = fileSystem.Path.Combine(outputDir, filename); - // Write file - await fileSystem.File.WriteAllTextAsync(filePath, yamlContent, ctx); + // Write file with explicit UTF-8 encoding to ensure proper character handling + await fileSystem.File.WriteAllTextAsync(filePath, yamlContent, Encoding.UTF8, ctx); logger.LogInformation("Created changelog fragment: {FilePath}", filePath); return true; diff --git a/src/services/Elastic.Changelog/GithubRelease/GitHubReleaseChangelogService.cs b/src/services/Elastic.Changelog/GithubRelease/GitHubReleaseChangelogService.cs index 2c379e3d1..a635593d8 100644 --- a/src/services/Elastic.Changelog/GithubRelease/GitHubReleaseChangelogService.cs +++ b/src/services/Elastic.Changelog/GithubRelease/GitHubReleaseChangelogService.cs @@ -273,7 +273,7 @@ private async Task ProcessPrReference( var slug = ChangelogTextUtilities.GenerateSlug(title); var filename = $"{prRef.PrNumber}-{finalType.ToStringFast(true)}-{slug}.yaml"; var filePath = _fileSystem.Path.Combine(outputDir, filename); - await _fileSystem.File.WriteAllTextAsync(filePath, yamlContent, ctx); + await _fileSystem.File.WriteAllTextAsync(filePath, yamlContent, Encoding.UTF8, ctx); createdFiles.Add(filename); _logger.LogDebug("Created changelog: {FilePath}", filePath); @@ -324,7 +324,7 @@ private async Task CreateBundleFile( // Name format: --bundle.yml var bundleFilename = $"{productInfo.Target}-{productInfo.Product}-bundle.yml"; var bundlePath = _fileSystem.Path.Combine(bundlesDir, bundleFilename); - await _fileSystem.File.WriteAllTextAsync(bundlePath, yamlContent, ctx); + await _fileSystem.File.WriteAllTextAsync(bundlePath, yamlContent, Encoding.UTF8, ctx); return bundlePath; } diff --git a/src/services/Elastic.Changelog/Rendering/Asciidoc/ChangelogAsciidocRenderer.cs b/src/services/Elastic.Changelog/Rendering/Asciidoc/ChangelogAsciidocRenderer.cs index d55ebad33..0fbb672c6 100644 --- a/src/services/Elastic.Changelog/Rendering/Asciidoc/ChangelogAsciidocRenderer.cs +++ b/src/services/Elastic.Changelog/Rendering/Asciidoc/ChangelogAsciidocRenderer.cs @@ -123,7 +123,7 @@ public async Task RenderAsciidoc(ChangelogRenderContext context, Cancel ctx) if (!string.IsNullOrWhiteSpace(asciidocDir) && !fileSystem.Directory.Exists(asciidocDir)) _ = fileSystem.Directory.CreateDirectory(asciidocDir); - await fileSystem.File.WriteAllTextAsync(asciidocPath, sb.ToString(), ctx); + await fileSystem.File.WriteAllTextAsync(asciidocPath, sb.ToString(), Encoding.UTF8, ctx); } private static void RenderSectionHeader(StringBuilder sb, string anchorPrefix, string titleSlug, string title) diff --git a/src/services/Elastic.Changelog/Rendering/Markdown/MarkdownRendererBase.cs b/src/services/Elastic.Changelog/Rendering/Markdown/MarkdownRendererBase.cs index ec4a59282..05f5bf6a7 100644 --- a/src/services/Elastic.Changelog/Rendering/Markdown/MarkdownRendererBase.cs +++ b/src/services/Elastic.Changelog/Rendering/Markdown/MarkdownRendererBase.cs @@ -31,7 +31,7 @@ protected async Task WriteOutputFileAsync(string outputDir, string titleSlug, st if (!string.IsNullOrWhiteSpace(outputDirectory) && !FileSystem.Directory.Exists(outputDirectory)) _ = FileSystem.Directory.CreateDirectory(outputDirectory); - await FileSystem.File.WriteAllTextAsync(outputPath, content, ctx); + await FileSystem.File.WriteAllTextAsync(outputPath, content, Encoding.UTF8, ctx); } /// diff --git a/src/services/Elastic.Changelog/Serialization/ChangelogYamlSerialization.cs b/src/services/Elastic.Changelog/Serialization/ChangelogYamlSerialization.cs index 88b9755b1..9771a481c 100644 --- a/src/services/Elastic.Changelog/Serialization/ChangelogYamlSerialization.cs +++ b/src/services/Elastic.Changelog/Serialization/ChangelogYamlSerialization.cs @@ -35,6 +35,8 @@ public static class ChangelogYamlSerialization new StaticSerializerBuilder(new ChangelogYamlStaticContext()) .WithNamingConvention(UnderscoredNamingConvention.Instance) .ConfigureDefaultValuesHandling(DefaultValuesHandling.OmitNull | DefaultValuesHandling.OmitEmptyCollections) + .WithQuotingNecessaryStrings() + .DisableAliases() .Build(); /// diff --git a/tests/Elastic.Changelog.Tests/Changelogs/BundleChangelogsTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/BundleChangelogsTests.cs index 2b978d681..1d3832949 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/BundleChangelogsTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/BundleChangelogsTests.cs @@ -1292,6 +1292,93 @@ public async Task BundleChangelogs_WithResolve_CopiesChangelogContents() bundleContent.Should().Contain("description: This is a test feature"); } + [Fact] + public async Task BundleChangelogs_WithResolve_PreservesSpecialCharactersInUtf8() + { + // Arrange - Create changelog with special characters that could be corrupted + // These characters were reported as being corrupted to "&o0" and "*o0" in the original issue + + // language=yaml + var changelog1 = + """ + title: Feature with special characters & symbols + type: feature + products: + - product: elasticsearch + target: 9.3.0 + lifecycle: ga + pr: https://github.com/elastic/elasticsearch/pull/100 + description: | + This feature includes special characters: + - Ampersand: & symbol + - Asterisk: * symbol + - Other special chars: < > " ' / \ + - Unicode: © ® ™ € £ ¥ + """; + + var file1 = FileSystem.Path.Combine(_changelogDir, "1755268130-special-chars.yaml"); + await FileSystem.File.WriteAllTextAsync(file1, changelog1, System.Text.Encoding.UTF8, TestContext.Current.CancellationToken); + + var outputPath = FileSystem.Path.Combine(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "bundle.yaml"); + var input = new BundleChangelogsArguments + { + Directory = _changelogDir, + All = true, + Resolve = true, + Output = outputPath + }; + + // Act + var result = await Service.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeTrue(); + Collector.Errors.Should().Be(0); + + // Read the bundle file with explicit UTF-8 encoding + var bundleContent = await FileSystem.File.ReadAllTextAsync(input.Output, System.Text.Encoding.UTF8, TestContext.Current.CancellationToken); + + // Verify special characters are preserved correctly (not corrupted) + // The original issue reported "&o0" and "*o0" corruption, so we verify the characters are correct + bundleContent.Should().Contain("&"); // Ampersand should be preserved + bundleContent.Should().Contain("Feature with special characters & symbols"); // Ampersand in title + bundleContent.Should().Contain("Ampersand: & symbol"); // Ampersand in description + + // Check that asterisk appears correctly (not corrupted to "*o0") + bundleContent.Should().Contain("*"); // Asterisk should be preserved + bundleContent.Should().Contain("Asterisk: * symbol"); // Asterisk in description + + // Verify the ampersand and asterisk are not corrupted + // The corruption pattern would be "&o0" or "*o0" appearing where we expect "&" or "*" + // We check that the title contains the correct pattern, not the corrupted one + var titleLine = bundleContent.Split('\n').FirstOrDefault(l => l.Contains("title:")); + titleLine.Should().NotBeNull(); + titleLine.Should().Contain("&"); + titleLine.Should().NotContain("&o0"); // Should not be corrupted in title + + // Verify no corruption patterns exist (these would indicate encoding issues) + bundleContent.Should().NotContain("&o0"); // Should not contain corrupted ampersand + bundleContent.Should().NotContain("*o0"); // Should not contain corrupted asterisk + + // Verify other special characters are preserved + bundleContent.Should().Contain("<"); + bundleContent.Should().Contain(">"); + bundleContent.Should().Contain("\""); + + // Verify Unicode characters are preserved + bundleContent.Should().Contain("©"); + bundleContent.Should().Contain("®"); + bundleContent.Should().Contain("™"); + bundleContent.Should().Contain("€"); + + // Verify the content structure is correct + bundleContent.Should().Contain("title: Feature with special characters & symbols"); + bundleContent.Should().Contain("type: feature"); + bundleContent.Should().Contain("product: elasticsearch"); + bundleContent.Should().Contain("target: 9.3.0"); + bundleContent.Should().Contain("lifecycle: ga"); + } + [Fact] public async Task BundleChangelogs_WithDirectoryOutputPath_CreatesDefaultFilename() {