From 5a3c8c384c959b328b5a2b8935449f7b6cc1304f Mon Sep 17 00:00:00 2001 From: Widthdom Date: Wed, 3 Jun 2026 01:39:46 +0900 Subject: [PATCH 1/8] Bound changelog release inputs (#2909) --- .codex/workflows/changelog-fragment.md | 4 ++ changelog.d/unreleased/2909.fixed.md | 17 +++++ tests/CodeIndex.Tests/ChangelogToolTests.cs | 70 +++++++++++++++++++++ tools/CodeIndex.Changelog/Program.cs | 47 ++++++++++++-- 4 files changed, 133 insertions(+), 5 deletions(-) create mode 100644 changelog.d/unreleased/2909.fixed.md diff --git a/.codex/workflows/changelog-fragment.md b/.codex/workflows/changelog-fragment.md index 588985ad59..efbd9391b9 100644 --- a/.codex/workflows/changelog-fragment.md +++ b/.codex/workflows/changelog-fragment.md @@ -42,6 +42,10 @@ with file-specific messages. For example, a non-issue fragment that writes `issues: null` fails with an invalid issue-number error; omit the `issues` field entirely instead. +The validator and release preparation tool reject oversized inputs before +parsing: at most 512 unreleased fragments, each fragment at most 131072 bytes, +`CHANGELOG.md` at most 2097152 bytes, and `version.json` at most 16384 bytes. + ## Template ```md diff --git a/changelog.d/unreleased/2909.fixed.md b/changelog.d/unreleased/2909.fixed.md new file mode 100644 index 0000000000..1b4afbbe3a --- /dev/null +++ b/changelog.d/unreleased/2909.fixed.md @@ -0,0 +1,17 @@ +--- +category: fixed +issues: + - 2909 +affected: + - tools/CodeIndex.Changelog/Program.cs + - tests/CodeIndex.Tests/ChangelogToolTests.cs + - .codex/workflows/changelog-fragment.md +--- + +## English + +- **Changelog release tooling now bounds fragment and metadata inputs (#2909)** — `check`, `prepare`, and `release-notes` reject excessive unreleased fragment counts and oversized fragment, changelog, or version files before parsing them. + +## 日本語 + +- **changelog release tool が fragment と metadata 入力の上限を検査するようになりました (#2909)** — `check`、`prepare`、`release-notes` は未リリース fragment 数、fragment、changelog、version file が過大な場合に parse 前に拒否します。 diff --git a/tests/CodeIndex.Tests/ChangelogToolTests.cs b/tests/CodeIndex.Tests/ChangelogToolTests.cs index 9ed3cffeb4..60cc0bf8fb 100644 --- a/tests/CodeIndex.Tests/ChangelogToolTests.cs +++ b/tests/CodeIndex.Tests/ChangelogToolTests.cs @@ -240,6 +240,74 @@ public void CheckFragmentsRejectsMissingJapaneseSection() Assert.Contains("missing '## 日本語' heading", ex.Message); } + [Fact] + public void CheckFragmentsRejectsTooManyFragmentsBeforeParsing() + { + using var scope = new TestRepositoryScope(); + scope.WriteFile("CHANGELOG.md", SampleChangelog); + scope.WriteFile("version.json", """ + { + "version": "1.16.0" + } + """); + + for (var i = 0; i <= ChangelogTool.MaxFragmentCount; i++) + scope.WriteFile($"changelog.d/unreleased/{1000 + i}.fixed.md", string.Empty); + + var tool = new ChangelogTool(scope.Root); + var ex = Assert.Throws(() => tool.CheckFragments()); + Assert.Contains("too many changelog fragments", ex.Message); + Assert.Contains($"maximum supported count is {ChangelogTool.MaxFragmentCount}", ex.Message); + } + + [Fact] + public void CheckFragmentsRejectsOversizedFragmentBeforeParsing() + { + using var scope = new TestRepositoryScope(); + scope.WriteFile("CHANGELOG.md", SampleChangelog); + scope.WriteFile("version.json", """ + { + "version": "1.16.0" + } + """); + scope.WriteFile("changelog.d/unreleased/+large.fixed.md", OversizedContent(ChangelogTool.MaxFragmentBytes)); + + var tool = new ChangelogTool(scope.Root); + var ex = Assert.Throws(() => tool.CheckFragments()); + Assert.Contains("changelog.d/unreleased/+large.fixed.md: file is", ex.Message); + Assert.Contains($"maximum supported size is {ChangelogTool.MaxFragmentBytes} bytes", ex.Message); + } + + [Fact] + public void PrepareRejectsOversizedChangelogBeforeParsing() + { + using var scope = new TestRepositoryScope(); + scope.WriteFile("CHANGELOG.md", OversizedContent(ChangelogTool.MaxChangelogBytes)); + scope.WriteFile("version.json", """ + { + "version": "1.16.0" + } + """); + + var tool = new ChangelogTool(scope.Root); + var ex = Assert.Throws(() => tool.Prepare(new Version(1, 17, 0), new DateOnly(2026, 5, 1), writeChanges: true)); + Assert.Contains("CHANGELOG.md: file is", ex.Message); + Assert.Contains($"maximum supported size is {ChangelogTool.MaxChangelogBytes} bytes", ex.Message); + } + + [Fact] + public void PrepareRejectsOversizedVersionBeforeParsing() + { + using var scope = new TestRepositoryScope(); + scope.WriteFile("CHANGELOG.md", SampleChangelog); + scope.WriteFile("version.json", OversizedContent(ChangelogTool.MaxVersionJsonBytes)); + + var tool = new ChangelogTool(scope.Root); + var ex = Assert.Throws(() => tool.Prepare(new Version(1, 17, 0), new DateOnly(2026, 5, 1), writeChanges: true)); + Assert.Contains("version.json: file is", ex.Message); + Assert.Contains($"maximum supported size is {ChangelogTool.MaxVersionJsonBytes} bytes", ex.Message); + } + private static int CountOccurrences(string text, string value) { var count = 0; @@ -253,6 +321,8 @@ private static int CountOccurrences(string text, string value) return count; } + private static string OversizedContent(long maxBytes) => new('x', checked((int)maxBytes + 1)); + private sealed class TestRepositoryScope : IDisposable { private readonly string _previousDirectory; diff --git a/tools/CodeIndex.Changelog/Program.cs b/tools/CodeIndex.Changelog/Program.cs index 87d6ab4f0a..7e536ce277 100644 --- a/tools/CodeIndex.Changelog/Program.cs +++ b/tools/CodeIndex.Changelog/Program.cs @@ -69,6 +69,12 @@ private static void PrintUsage() Console.Out.WriteLine(" dotnet run --project tools/CodeIndex.Changelog -- prepare --version X.Y.Z --date YYYY-MM-DD"); Console.Out.WriteLine(" dotnet run --project tools/CodeIndex.Changelog -- render --version X.Y.Z --date YYYY-MM-DD"); Console.Out.WriteLine(" dotnet run --project tools/CodeIndex.Changelog -- release-notes --version X.Y.Z"); + Console.Out.WriteLine(); + Console.Out.WriteLine("Limits:"); + Console.Out.WriteLine($" unreleased fragments: {ChangelogTool.MaxFragmentCount}"); + Console.Out.WriteLine($" fragment file size: {ChangelogTool.MaxFragmentBytes} bytes"); + Console.Out.WriteLine($" CHANGELOG.md size: {ChangelogTool.MaxChangelogBytes} bytes"); + Console.Out.WriteLine($" version.json size: {ChangelogTool.MaxVersionJsonBytes} bytes"); } private static ParsedOptions ParseOptions(string[] args, bool requireDate) @@ -146,6 +152,11 @@ private sealed record ParsedOptions(Version Version, DateOnly ReleaseDate); public sealed class ChangelogTool { + public const int MaxFragmentCount = 512; + public const long MaxFragmentBytes = 128 * 1024; + public const long MaxChangelogBytes = 2 * 1024 * 1024; + public const long MaxVersionJsonBytes = 16 * 1024; + private static readonly string[] AllowedCategories = [ "added", @@ -211,7 +222,7 @@ public PrepareResult Prepare(Version targetVersion, DateOnly releaseDate, bool w var currentVersion = ReadCurrentVersion(versionPath); var changelogPath = Path.Combine(_repositoryRoot, "CHANGELOG.md"); - var changelogText = File.ReadAllText(changelogPath).Replace("\r\n", "\n", StringComparison.Ordinal); + var changelogText = ReadAllTextBounded(changelogPath, _repositoryRoot, MaxChangelogBytes).Replace("\r\n", "\n", StringComparison.Ordinal); var changelog = ParsedChangelog.Parse(changelogText); var targetHeading = $"### [{targetVersion}] - {releaseDate:yyyy-MM-dd}"; @@ -268,7 +279,7 @@ public PrepareResult Prepare(Version targetVersion, DateOnly releaseDate, bool w public string RenderReleaseNotes(Version targetVersion) { var changelogPath = Path.Combine(_repositoryRoot, "CHANGELOG.md"); - var changelogText = File.ReadAllText(changelogPath).Replace("\r\n", "\n", StringComparison.Ordinal); + var changelogText = ReadAllTextBounded(changelogPath, _repositoryRoot, MaxChangelogBytes).Replace("\r\n", "\n", StringComparison.Ordinal); var changelog = ParsedChangelog.Parse(changelogText); var versionPrefix = $"### [{targetVersion}]"; @@ -304,13 +315,23 @@ private List LoadFragments(bool validate) var fragments = new List(); var errors = new List(); + var fragmentPaths = new List(); - foreach (var path in Directory.EnumerateFiles(fragmentDirectory, "*.md", SearchOption.TopDirectoryOnly).OrderBy(path => path, StringComparer.Ordinal)) + foreach (var path in Directory.EnumerateFiles(fragmentDirectory, "*.md", SearchOption.TopDirectoryOnly)) { var fileName = Path.GetFileName(path); if (fileName == ".gitkeep") continue; + fragmentPaths.Add(path); + if (fragmentPaths.Count > MaxFragmentCount) + throw new ChangelogException($"changelog.d/unreleased: too many changelog fragments ({fragmentPaths.Count}); maximum supported count is {MaxFragmentCount}."); + } + + fragmentPaths.Sort(StringComparer.Ordinal); + + foreach (var path in fragmentPaths) + { try { fragments.Add(ParseFragment(path, _repositoryRoot)); @@ -344,7 +365,7 @@ private static Fragment ParseFragment(string absolutePath, string repositoryRoot var frontMatterIssues = new List(); var frontMatterAffected = new List(); - var text = File.ReadAllText(absolutePath).Replace("\r\n", "\n", StringComparison.Ordinal); + var text = ReadAllTextBounded(absolutePath, repositoryRoot, MaxFragmentBytes).Replace("\r\n", "\n", StringComparison.Ordinal); var lines = text.Split('\n'); if (lines.Length == 0) throw new ChangelogException($"{relativePath}: fragment is empty."); @@ -518,7 +539,8 @@ private static string[] TrimTrailingAndLeadingBlankLines(string[] lines) private static Version ReadCurrentVersion(string versionPath) { - var text = File.ReadAllText(versionPath); + var repositoryRoot = Path.GetDirectoryName(versionPath) ?? string.Empty; + var text = ReadAllTextBounded(versionPath, repositoryRoot, MaxVersionJsonBytes); using var document = JsonDocument.Parse(text); if (!document.RootElement.TryGetProperty("version", out var versionElement)) throw new ChangelogException("version.json is missing the version property."); @@ -526,6 +548,21 @@ private static Version ReadCurrentVersion(string versionPath) return Version.Parse(versionElement.GetString() ?? throw new ChangelogException("version.json contains an empty version.")); } + private static string ReadAllTextBounded(string absolutePath, string repositoryRoot, long maxBytes) + { + var fileInfo = new FileInfo(absolutePath); + var length = fileInfo.Length; + if (length > maxBytes) + { + var relativePath = string.IsNullOrWhiteSpace(repositoryRoot) + ? Path.GetFileName(absolutePath) + : Path.GetRelativePath(repositoryRoot, absolutePath).Replace('\\', '/'); + throw new ChangelogException($"{relativePath}: file is {length} bytes; maximum supported size is {maxBytes} bytes."); + } + + return File.ReadAllText(absolutePath); + } + private static List PrepareLanguageSection( IReadOnlyList existingBlocks, Version targetVersion, From d32dcd2febb3ff93c09764cf49bc0be6e729fa29 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Wed, 3 Jun 2026 01:50:13 +0900 Subject: [PATCH 2/8] Stage changelog release writes (#2910) --- changelog.d/unreleased/2910.fixed.md | 17 +++ tests/CodeIndex.Tests/ChangelogToolTests.cs | 79 ++++++++++++ .../CodeIndex.Changelog.csproj | 4 + tools/CodeIndex.Changelog/Program.cs | 114 +++++++++++++++++- 4 files changed, 209 insertions(+), 5 deletions(-) create mode 100644 changelog.d/unreleased/2910.fixed.md diff --git a/changelog.d/unreleased/2910.fixed.md b/changelog.d/unreleased/2910.fixed.md new file mode 100644 index 0000000000..82b4e4cba5 --- /dev/null +++ b/changelog.d/unreleased/2910.fixed.md @@ -0,0 +1,17 @@ +--- +category: fixed +issues: + - 2910 +affected: + - tools/CodeIndex.Changelog/Program.cs + - tools/CodeIndex.Changelog/CodeIndex.Changelog.csproj + - tests/CodeIndex.Tests/ChangelogToolTests.cs +--- + +## English + +- **Changelog release preparation now stages writes before deleting fragments (#2910)** — `prepare` writes temporary changelog and version files, replaces both release files before deleting consumed fragments, and leaves fragment cleanup recoverable when deletion fails. + +## 日本語 + +- **changelog release preparation が fragment 削除前に書き込みを stage するようになりました (#2910)** — `prepare` は changelog と version の一時ファイルを書いてから両方の release file を置換し、consumed fragment の削除に失敗しても復旧しやすい状態を残します。 diff --git a/tests/CodeIndex.Tests/ChangelogToolTests.cs b/tests/CodeIndex.Tests/ChangelogToolTests.cs index 60cc0bf8fb..fc0b3da468 100644 --- a/tests/CodeIndex.Tests/ChangelogToolTests.cs +++ b/tests/CodeIndex.Tests/ChangelogToolTests.cs @@ -127,6 +127,85 @@ public void PrepareRerunPreservesExistingReleaseAndAppendsNewFragments() Assert.Equal(0, scope.ListFiles("changelog.d/unreleased").Count(path => Path.GetFileName(path) is "195.fixed.md" or "+release-process.docs.md")); } + [Fact] + public void PrepareFailureAfterStagingLeavesReleaseFilesAndFragmentsUntouched() + { + using var scope = new TestRepositoryScope(); + scope.WriteFile("CHANGELOG.md", SampleChangelog); + scope.WriteFile("version.json", """ + { + "version": "1.16.0" + } + """); + scope.WriteFile("changelog.d/unreleased/195.fixed.md", SampleFragment); + + var tool = new ChangelogTool(scope.Root); + ChangelogException? ex = null; + ChangelogTool.PrepareWritePhaseForTesting = phase => + { + if (phase == PrepareWritePhase.StagedFilesWritten) + throw new ChangelogException("injected staging failure"); + }; + try + { + ex = Assert.Throws(() => tool.Prepare(new Version(1, 17, 0), new DateOnly(2026, 5, 1), writeChanges: true)); + } + finally + { + ChangelogTool.PrepareWritePhaseForTesting = null; + } + + Assert.NotNull(ex); + Assert.Contains("injected staging failure", ex.Message); + Assert.DoesNotContain("English release note", scope.ReadFile("CHANGELOG.md")); + Assert.Equal(""" + { + "version": "1.16.0" + } + """.Replace("\r\n", "\n"), scope.ReadFile("version.json").Replace("\r\n", "\n")); + Assert.True(scope.Exists("changelog.d/unreleased/195.fixed.md")); + Assert.DoesNotContain(scope.ListFiles("."), path => Path.GetFileName(path).EndsWith(".tmp", StringComparison.Ordinal)); + } + + [Fact] + public void PrepareFailureBeforeFragmentDeletionLeavesFragmentsForManualRecovery() + { + using var scope = new TestRepositoryScope(); + scope.WriteFile("CHANGELOG.md", SampleChangelog); + scope.WriteFile("version.json", """ + { + "version": "1.16.0" + } + """); + scope.WriteFile("changelog.d/unreleased/195.fixed.md", SampleFragment); + + var tool = new ChangelogTool(scope.Root); + ChangelogException? ex = null; + ChangelogTool.PrepareWritePhaseForTesting = phase => + { + if (phase == PrepareWritePhase.BeforeFragmentsDeleted) + throw new ChangelogException("injected fragment deletion failure"); + }; + try + { + ex = Assert.Throws(() => tool.Prepare(new Version(1, 17, 0), new DateOnly(2026, 5, 1), writeChanges: true)); + } + finally + { + ChangelogTool.PrepareWritePhaseForTesting = null; + } + + Assert.NotNull(ex); + Assert.Contains("injected fragment deletion failure", ex.Message); + Assert.Contains("English release note", scope.ReadFile("CHANGELOG.md")); + Assert.Equal(""" + { + "version": "1.17.0" + } + """.Replace("\r\n", "\n") + "\n", scope.ReadFile("version.json").Replace("\r\n", "\n")); + Assert.True(scope.Exists("changelog.d/unreleased/195.fixed.md")); + } + [Fact] public void RenderReleaseNotesExtractsMatchingEnglishAndJapaneseSections() { diff --git a/tools/CodeIndex.Changelog/CodeIndex.Changelog.csproj b/tools/CodeIndex.Changelog/CodeIndex.Changelog.csproj index 2b8d4aed18..839391dd3c 100644 --- a/tools/CodeIndex.Changelog/CodeIndex.Changelog.csproj +++ b/tools/CodeIndex.Changelog/CodeIndex.Changelog.csproj @@ -8,4 +8,8 @@ false + + + + diff --git a/tools/CodeIndex.Changelog/Program.cs b/tools/CodeIndex.Changelog/Program.cs index 7e536ce277..d84a182551 100644 --- a/tools/CodeIndex.Changelog/Program.cs +++ b/tools/CodeIndex.Changelog/Program.cs @@ -156,6 +156,7 @@ public sealed class ChangelogTool public const long MaxFragmentBytes = 128 * 1024; public const long MaxChangelogBytes = 2 * 1024 * 1024; public const long MaxVersionJsonBytes = 16 * 1024; + internal static Action? PrepareWritePhaseForTesting { get; set; } private static readonly string[] AllowedCategories = [ @@ -250,13 +251,15 @@ public PrepareResult Prepare(Version targetVersion, DateOnly releaseDate, bool w var updatedChangelog = changelog.Render(english, japanese, footerEntries); var consumedFragmentFiles = fragments.Select(fragment => fragment.RelativePath).ToList(); + var updatedVersionJson = JsonSerializer.Serialize(new { version = targetVersion.ToString() }, new JsonSerializerOptions { WriteIndented = true }) + Environment.NewLine; if (writeChanges) { - File.WriteAllText(changelogPath, updatedChangelog); - File.WriteAllText(versionPath, JsonSerializer.Serialize(new { version = targetVersion.ToString() }, new JsonSerializerOptions { WriteIndented = true }) + Environment.NewLine); - - foreach (var fragment in fragments) - File.Delete(fragment.AbsolutePath); + WritePreparedFiles( + changelogPath, + updatedChangelog, + versionPath, + updatedVersionJson, + fragments); } var changedFiles = new List @@ -563,6 +566,99 @@ private static string ReadAllTextBounded(string absolutePath, string repositoryR return File.ReadAllText(absolutePath); } + private static void WritePreparedFiles( + string changelogPath, + string updatedChangelog, + string versionPath, + string updatedVersionJson, + IReadOnlyList fragments) + { + var changelogTempPath = string.Empty; + var versionTempPath = string.Empty; + + try + { + changelogTempPath = WriteStagedText(changelogPath, updatedChangelog); + versionTempPath = WriteStagedText(versionPath, updatedVersionJson); + + NotifyPrepareWritePhase(PrepareWritePhase.StagedFilesWritten); + + ReplaceWithStagedFile(changelogTempPath, changelogPath); + changelogTempPath = string.Empty; + NotifyPrepareWritePhase(PrepareWritePhase.ChangelogReplaced); + + ReplaceWithStagedFile(versionTempPath, versionPath); + versionTempPath = string.Empty; + NotifyPrepareWritePhase(PrepareWritePhase.VersionReplaced); + + NotifyPrepareWritePhase(PrepareWritePhase.BeforeFragmentsDeleted); + foreach (var fragment in fragments) + DeleteConsumedFragment(fragment); + } + finally + { + TryDelete(changelogTempPath); + TryDelete(versionTempPath); + } + } + + private static string WriteStagedText(string targetPath, string contents) + { + var tempPath = BuildTempPath(targetPath); + using var stream = new FileStream(tempPath, FileMode.CreateNew, FileAccess.Write, FileShare.None); + using var writer = new StreamWriter(stream, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), bufferSize: 1024, leaveOpen: true); + writer.Write(contents); + writer.Flush(); + stream.Flush(flushToDisk: true); + return tempPath; + } + + private static void ReplaceWithStagedFile(string stagedPath, string targetPath) + { + File.Move(stagedPath, targetPath, overwrite: true); + } + + private static void DeleteConsumedFragment(Fragment fragment) + { + try + { + File.Delete(fragment.AbsolutePath); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + throw new ChangelogException($"{fragment.RelativePath}: failed to delete consumed changelog fragment after CHANGELOG.md and version.json were updated; delete this fragment manually before retrying prepare. {ex.Message}"); + } + } + + private static string BuildTempPath(string targetPath) + { + var directory = Path.GetDirectoryName(targetPath); + var fileName = Path.GetFileName(targetPath); + var tempFileName = $".{fileName}.{Guid.NewGuid():N}.tmp"; + return string.IsNullOrEmpty(directory) + ? tempFileName + : Path.Combine(directory, tempFileName); + } + + private static void NotifyPrepareWritePhase(PrepareWritePhase phase) + { + PrepareWritePhaseForTesting?.Invoke(phase); + } + + private static void TryDelete(string path) + { + if (string.IsNullOrEmpty(path)) + return; + + try + { + File.Delete(path); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + } + } + private static List PrepareLanguageSection( IReadOnlyList existingBlocks, Version targetVersion, @@ -889,6 +985,14 @@ private enum Language } } +internal enum PrepareWritePhase +{ + StagedFilesWritten, + ChangelogReplaced, + VersionReplaced, + BeforeFragmentsDeleted, +} + public sealed record PrepareResult(string Summary, string? RenderedChangelog); public sealed class ChangelogException : Exception From 4eabee27ae0ece28f12c02783cc1740a62174e4a Mon Sep 17 00:00:00 2001 From: Widthdom Date: Wed, 3 Jun 2026 02:06:04 +0900 Subject: [PATCH 3/8] Roll back staged changelog prepare failures (#2910) --- changelog.d/unreleased/2910.fixed.md | 4 +- tests/CodeIndex.Tests/ChangelogToolTests.cs | 9 +-- tools/CodeIndex.Changelog/Program.cs | 68 +++++++++++++++++++-- 3 files changed, 70 insertions(+), 11 deletions(-) diff --git a/changelog.d/unreleased/2910.fixed.md b/changelog.d/unreleased/2910.fixed.md index 82b4e4cba5..113ac7c7bb 100644 --- a/changelog.d/unreleased/2910.fixed.md +++ b/changelog.d/unreleased/2910.fixed.md @@ -10,8 +10,8 @@ affected: ## English -- **Changelog release preparation now stages writes before deleting fragments (#2910)** — `prepare` writes temporary changelog and version files, replaces both release files before deleting consumed fragments, and leaves fragment cleanup recoverable when deletion fails. +- **Changelog release preparation now stages writes before deleting fragments (#2910)** — `prepare` writes temporary changelog and version files, rolls release files back when staging or replacement fails before fragment deletion starts, and deletes consumed fragments only after both release files have been replaced. ## 日本語 -- **changelog release preparation が fragment 削除前に書き込みを stage するようになりました (#2910)** — `prepare` は changelog と version の一時ファイルを書いてから両方の release file を置換し、consumed fragment の削除に失敗しても復旧しやすい状態を残します。 +- **changelog release preparation が fragment 削除前に書き込みを stage するようになりました (#2910)** — `prepare` は changelog と version の一時ファイルを書き、fragment 削除が始まる前の stage / replacement 失敗時は release file を元へ戻し、両方の release file を置換してから consumed fragment を削除します。 diff --git a/tests/CodeIndex.Tests/ChangelogToolTests.cs b/tests/CodeIndex.Tests/ChangelogToolTests.cs index fc0b3da468..ff482d7db5 100644 --- a/tests/CodeIndex.Tests/ChangelogToolTests.cs +++ b/tests/CodeIndex.Tests/ChangelogToolTests.cs @@ -168,7 +168,7 @@ public void PrepareFailureAfterStagingLeavesReleaseFilesAndFragmentsUntouched() } [Fact] - public void PrepareFailureBeforeFragmentDeletionLeavesFragmentsForManualRecovery() + public void PrepareFailureBeforeFragmentDeletionRollsBackReleaseFiles() { using var scope = new TestRepositoryScope(); scope.WriteFile("CHANGELOG.md", SampleChangelog); @@ -197,13 +197,14 @@ public void PrepareFailureBeforeFragmentDeletionLeavesFragmentsForManualRecovery Assert.NotNull(ex); Assert.Contains("injected fragment deletion failure", ex.Message); - Assert.Contains("English release note", scope.ReadFile("CHANGELOG.md")); + Assert.DoesNotContain("English release note", scope.ReadFile("CHANGELOG.md")); Assert.Equal(""" { - "version": "1.17.0" + "version": "1.16.0" } - """.Replace("\r\n", "\n") + "\n", scope.ReadFile("version.json").Replace("\r\n", "\n")); + """.Replace("\r\n", "\n"), scope.ReadFile("version.json").Replace("\r\n", "\n")); Assert.True(scope.Exists("changelog.d/unreleased/195.fixed.md")); + Assert.DoesNotContain(scope.ListFiles("."), path => Path.GetFileName(path).EndsWith(".tmp", StringComparison.Ordinal)); } [Fact] diff --git a/tools/CodeIndex.Changelog/Program.cs b/tools/CodeIndex.Changelog/Program.cs index d84a182551..6b9363943d 100644 --- a/tools/CodeIndex.Changelog/Program.cs +++ b/tools/CodeIndex.Changelog/Program.cs @@ -220,10 +220,12 @@ public PrepareResult Prepare(Version targetVersion, DateOnly releaseDate, bool w { var fragments = LoadFragments(validate: true); var versionPath = Path.Combine(_repositoryRoot, "version.json"); - var currentVersion = ReadCurrentVersion(versionPath); + var originalVersionJson = ReadAllTextBounded(versionPath, _repositoryRoot, MaxVersionJsonBytes); + var currentVersion = ParseCurrentVersion(originalVersionJson); var changelogPath = Path.Combine(_repositoryRoot, "CHANGELOG.md"); - var changelogText = ReadAllTextBounded(changelogPath, _repositoryRoot, MaxChangelogBytes).Replace("\r\n", "\n", StringComparison.Ordinal); + var originalChangelogText = ReadAllTextBounded(changelogPath, _repositoryRoot, MaxChangelogBytes); + var changelogText = originalChangelogText.Replace("\r\n", "\n", StringComparison.Ordinal); var changelog = ParsedChangelog.Parse(changelogText); var targetHeading = $"### [{targetVersion}] - {releaseDate:yyyy-MM-dd}"; @@ -256,8 +258,10 @@ public PrepareResult Prepare(Version targetVersion, DateOnly releaseDate, bool w { WritePreparedFiles( changelogPath, + originalChangelogText, updatedChangelog, versionPath, + originalVersionJson, updatedVersionJson, fragments); } @@ -540,10 +544,8 @@ private static string[] TrimTrailingAndLeadingBlankLines(string[] lines) return start <= end ? lines[start..(end + 1)] : []; } - private static Version ReadCurrentVersion(string versionPath) + private static Version ParseCurrentVersion(string text) { - var repositoryRoot = Path.GetDirectoryName(versionPath) ?? string.Empty; - var text = ReadAllTextBounded(versionPath, repositoryRoot, MaxVersionJsonBytes); using var document = JsonDocument.Parse(text); if (!document.RootElement.TryGetProperty("version", out var versionElement)) throw new ChangelogException("version.json is missing the version property."); @@ -568,13 +570,18 @@ private static string ReadAllTextBounded(string absolutePath, string repositoryR private static void WritePreparedFiles( string changelogPath, + string originalChangelog, string updatedChangelog, string versionPath, + string originalVersionJson, string updatedVersionJson, IReadOnlyList fragments) { var changelogTempPath = string.Empty; var versionTempPath = string.Empty; + var changelogReplaced = false; + var versionReplaced = false; + var fragmentDeletionStarted = false; try { @@ -585,16 +592,30 @@ private static void WritePreparedFiles( ReplaceWithStagedFile(changelogTempPath, changelogPath); changelogTempPath = string.Empty; + changelogReplaced = true; NotifyPrepareWritePhase(PrepareWritePhase.ChangelogReplaced); ReplaceWithStagedFile(versionTempPath, versionPath); versionTempPath = string.Empty; + versionReplaced = true; NotifyPrepareWritePhase(PrepareWritePhase.VersionReplaced); NotifyPrepareWritePhase(PrepareWritePhase.BeforeFragmentsDeleted); + fragmentDeletionStarted = true; foreach (var fragment in fragments) DeleteConsumedFragment(fragment); } + catch (Exception) when (!fragmentDeletionStarted) + { + RollBackPreparedFiles( + changelogPath, + originalChangelog, + changelogReplaced, + versionPath, + originalVersionJson, + versionReplaced); + throw; + } finally { TryDelete(changelogTempPath); @@ -630,6 +651,43 @@ private static void DeleteConsumedFragment(Fragment fragment) } } + private static void RollBackPreparedFiles( + string changelogPath, + string originalChangelog, + bool changelogReplaced, + string versionPath, + string originalVersionJson, + bool versionReplaced) + { + try + { + if (versionReplaced) + RestoreText(versionPath, originalVersionJson); + + if (changelogReplaced) + RestoreText(changelogPath, originalChangelog); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + throw new ChangelogException($"prepare failed before fragment deletion and rollback also failed: {ex.Message}"); + } + } + + private static void RestoreText(string targetPath, string contents) + { + var tempPath = string.Empty; + try + { + tempPath = WriteStagedText(targetPath, contents); + ReplaceWithStagedFile(tempPath, targetPath); + tempPath = string.Empty; + } + finally + { + TryDelete(tempPath); + } + } + private static string BuildTempPath(string targetPath) { var directory = Path.GetDirectoryName(targetPath); From abebc98a1ebb3655553adcd4dee77f18233c3033 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Wed, 3 Jun 2026 02:14:55 +0900 Subject: [PATCH 4/8] Raise changelog size cap (#2909) --- .codex/workflows/changelog-fragment.md | 2 +- tests/CodeIndex.Tests/ChangelogToolTests.cs | 28 +++++++++++++++++++++ tools/CodeIndex.Changelog/Program.cs | 2 +- 3 files changed, 30 insertions(+), 2 deletions(-) diff --git a/.codex/workflows/changelog-fragment.md b/.codex/workflows/changelog-fragment.md index efbd9391b9..2a916ce7ed 100644 --- a/.codex/workflows/changelog-fragment.md +++ b/.codex/workflows/changelog-fragment.md @@ -44,7 +44,7 @@ field entirely instead. The validator and release preparation tool reject oversized inputs before parsing: at most 512 unreleased fragments, each fragment at most 131072 bytes, -`CHANGELOG.md` at most 2097152 bytes, and `version.json` at most 16384 bytes. +`CHANGELOG.md` at most 8388608 bytes, and `version.json` at most 16384 bytes. ## Template diff --git a/tests/CodeIndex.Tests/ChangelogToolTests.cs b/tests/CodeIndex.Tests/ChangelogToolTests.cs index ff482d7db5..32204bfd0c 100644 --- a/tests/CodeIndex.Tests/ChangelogToolTests.cs +++ b/tests/CodeIndex.Tests/ChangelogToolTests.cs @@ -375,6 +375,17 @@ public void PrepareRejectsOversizedChangelogBeforeParsing() Assert.Contains($"maximum supported size is {ChangelogTool.MaxChangelogBytes} bytes", ex.Message); } + [Fact] + public void ConfiguredChangelogLimitFitsRepositoryChangelog() + { + var repositoryRoot = FindRepositoryRootForTest(); + var changelogLength = new FileInfo(Path.Combine(repositoryRoot, "CHANGELOG.md")).Length; + + Assert.True( + changelogLength <= ChangelogTool.MaxChangelogBytes, + $"CHANGELOG.md is {changelogLength} bytes, but MaxChangelogBytes is {ChangelogTool.MaxChangelogBytes}."); + } + [Fact] public void PrepareRejectsOversizedVersionBeforeParsing() { @@ -403,6 +414,23 @@ private static int CountOccurrences(string text, string value) private static string OversizedContent(long maxBytes) => new('x', checked((int)maxBytes + 1)); + private static string FindRepositoryRootForTest() + { + var current = new DirectoryInfo(AppContext.BaseDirectory); + while (current is not null) + { + if (File.Exists(Path.Combine(current.FullName, "CHANGELOG.md")) && + File.Exists(Path.Combine(current.FullName, "CodeIndex.sln"))) + { + return current.FullName; + } + + current = current.Parent; + } + + throw new InvalidOperationException("Could not locate repository root."); + } + private sealed class TestRepositoryScope : IDisposable { private readonly string _previousDirectory; diff --git a/tools/CodeIndex.Changelog/Program.cs b/tools/CodeIndex.Changelog/Program.cs index 6b9363943d..ddccae20e4 100644 --- a/tools/CodeIndex.Changelog/Program.cs +++ b/tools/CodeIndex.Changelog/Program.cs @@ -154,7 +154,7 @@ public sealed class ChangelogTool { public const int MaxFragmentCount = 512; public const long MaxFragmentBytes = 128 * 1024; - public const long MaxChangelogBytes = 2 * 1024 * 1024; + public const long MaxChangelogBytes = 8 * 1024 * 1024; public const long MaxVersionJsonBytes = 16 * 1024; internal static Action? PrepareWritePhaseForTesting { get; set; } From 913a36abcc70779953b44865766a35fb3726aaa2 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Wed, 3 Jun 2026 02:24:44 +0900 Subject: [PATCH 5/8] Clean up staged changelog temp writes (#2910) --- changelog.d/unreleased/2910.fixed.md | 4 +-- tests/CodeIndex.Tests/ChangelogToolTests.cs | 40 +++++++++++++++++++++ tools/CodeIndex.Changelog/Program.cs | 29 ++++++++++++--- 3 files changed, 66 insertions(+), 7 deletions(-) diff --git a/changelog.d/unreleased/2910.fixed.md b/changelog.d/unreleased/2910.fixed.md index 113ac7c7bb..7b5ea32c50 100644 --- a/changelog.d/unreleased/2910.fixed.md +++ b/changelog.d/unreleased/2910.fixed.md @@ -10,8 +10,8 @@ affected: ## English -- **Changelog release preparation now stages writes before deleting fragments (#2910)** — `prepare` writes temporary changelog and version files, rolls release files back when staging or replacement fails before fragment deletion starts, and deletes consumed fragments only after both release files have been replaced. +- **Changelog release preparation now stages writes before deleting fragments (#2910)** — `prepare` writes temporary changelog and version files, deletes partial temp files when staged writes fail, rolls release files back when staging or replacement fails before fragment deletion starts, and deletes consumed fragments only after both release files have been replaced. ## 日本語 -- **changelog release preparation が fragment 削除前に書き込みを stage するようになりました (#2910)** — `prepare` は changelog と version の一時ファイルを書き、fragment 削除が始まる前の stage / replacement 失敗時は release file を元へ戻し、両方の release file を置換してから consumed fragment を削除します。 +- **changelog release preparation が fragment 削除前に書き込みを stage するようになりました (#2910)** — `prepare` は changelog と version の一時ファイルを書き、stage 書き込み失敗時は部分的な temp file を削除し、fragment 削除が始まる前の stage / replacement 失敗時は release file を元へ戻し、両方の release file を置換してから consumed fragment を削除します。 diff --git a/tests/CodeIndex.Tests/ChangelogToolTests.cs b/tests/CodeIndex.Tests/ChangelogToolTests.cs index 32204bfd0c..adfc4a96c6 100644 --- a/tests/CodeIndex.Tests/ChangelogToolTests.cs +++ b/tests/CodeIndex.Tests/ChangelogToolTests.cs @@ -167,6 +167,46 @@ public void PrepareFailureAfterStagingLeavesReleaseFilesAndFragmentsUntouched() Assert.DoesNotContain(scope.ListFiles("."), path => Path.GetFileName(path).EndsWith(".tmp", StringComparison.Ordinal)); } + [Fact] + public void PrepareFailureDuringStagedWriteDeletesPartialTempFile() + { + using var scope = new TestRepositoryScope(); + scope.WriteFile("CHANGELOG.md", SampleChangelog); + scope.WriteFile("version.json", """ + { + "version": "1.16.0" + } + """); + scope.WriteFile("changelog.d/unreleased/195.fixed.md", SampleFragment); + + var tool = new ChangelogTool(scope.Root); + ChangelogException? ex = null; + ChangelogTool.PrepareWritePhaseForTesting = phase => + { + if (phase == PrepareWritePhase.StagedTempCreated) + throw new ChangelogException("injected staged write failure"); + }; + try + { + ex = Assert.Throws(() => tool.Prepare(new Version(1, 17, 0), new DateOnly(2026, 5, 1), writeChanges: true)); + } + finally + { + ChangelogTool.PrepareWritePhaseForTesting = null; + } + + Assert.NotNull(ex); + Assert.Contains("injected staged write failure", ex.Message); + Assert.DoesNotContain("English release note", scope.ReadFile("CHANGELOG.md")); + Assert.Equal(""" + { + "version": "1.16.0" + } + """.Replace("\r\n", "\n"), scope.ReadFile("version.json").Replace("\r\n", "\n")); + Assert.True(scope.Exists("changelog.d/unreleased/195.fixed.md")); + Assert.DoesNotContain(scope.ListFiles("."), path => Path.GetFileName(path).EndsWith(".tmp", StringComparison.Ordinal)); + } + [Fact] public void PrepareFailureBeforeFragmentDeletionRollsBackReleaseFiles() { diff --git a/tools/CodeIndex.Changelog/Program.cs b/tools/CodeIndex.Changelog/Program.cs index ddccae20e4..0f537974d6 100644 --- a/tools/CodeIndex.Changelog/Program.cs +++ b/tools/CodeIndex.Changelog/Program.cs @@ -626,11 +626,29 @@ private static void WritePreparedFiles( private static string WriteStagedText(string targetPath, string contents) { var tempPath = BuildTempPath(targetPath); - using var stream = new FileStream(tempPath, FileMode.CreateNew, FileAccess.Write, FileShare.None); - using var writer = new StreamWriter(stream, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), bufferSize: 1024, leaveOpen: true); - writer.Write(contents); - writer.Flush(); - stream.Flush(flushToDisk: true); + var tempCreated = false; + var writeCompleted = false; + try + { + using (var stream = new FileStream(tempPath, FileMode.CreateNew, FileAccess.Write, FileShare.None)) + { + tempCreated = true; + NotifyPrepareWritePhase(PrepareWritePhase.StagedTempCreated); + + using var writer = new StreamWriter(stream, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), bufferSize: 1024, leaveOpen: true); + writer.Write(contents); + writer.Flush(); + stream.Flush(flushToDisk: true); + } + + writeCompleted = true; + } + finally + { + if (tempCreated && !writeCompleted) + TryDelete(tempPath); + } + return tempPath; } @@ -1045,6 +1063,7 @@ private enum Language internal enum PrepareWritePhase { + StagedTempCreated, StagedFilesWritten, ChangelogReplaced, VersionReplaced, From 6276f4754a4e11039ab8b31d96c1806d35bfc21a Mon Sep 17 00:00:00 2001 From: Widthdom Date: Wed, 3 Jun 2026 02:32:43 +0900 Subject: [PATCH 6/8] Clarify changelog input limit notes (#2909) --- changelog.d/unreleased/2909.fixed.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/changelog.d/unreleased/2909.fixed.md b/changelog.d/unreleased/2909.fixed.md index 1b4afbbe3a..2ec68c063b 100644 --- a/changelog.d/unreleased/2909.fixed.md +++ b/changelog.d/unreleased/2909.fixed.md @@ -10,8 +10,8 @@ affected: ## English -- **Changelog release tooling now bounds fragment and metadata inputs (#2909)** — `check`, `prepare`, and `release-notes` reject excessive unreleased fragment counts and oversized fragment, changelog, or version files before parsing them. +- **Changelog release tooling now bounds the inputs each command reads (#2909)** — `check` rejects excessive unreleased fragment counts and oversized fragment files, `prepare` also rejects oversized `CHANGELOG.md` and `version.json`, and `release-notes` rejects oversized `CHANGELOG.md` before parsing. ## 日本語 -- **changelog release tool が fragment と metadata 入力の上限を検査するようになりました (#2909)** — `check`、`prepare`、`release-notes` は未リリース fragment 数、fragment、changelog、version file が過大な場合に parse 前に拒否します。 +- **changelog release tool が各 command の読み取る入力に上限を適用するようになりました (#2909)** — `check` は未リリース fragment 数と fragment file size、`prepare` はそれに加えて `CHANGELOG.md` と `version.json`、`release-notes` は `CHANGELOG.md` が過大な場合に parse 前に拒否します。 From 05f230bb07d1ebfcf1694e32b7e62cba111fab0b Mon Sep 17 00:00:00 2001 From: Widthdom Date: Wed, 3 Jun 2026 02:41:13 +0900 Subject: [PATCH 7/8] Bound changelog reads through symlinks (#2909) --- tests/CodeIndex.Tests/ChangelogToolTests.cs | 29 +++++++++++++++++++ tools/CodeIndex.Changelog/Program.cs | 31 +++++++++++++++++++-- 2 files changed, 58 insertions(+), 2 deletions(-) diff --git a/tests/CodeIndex.Tests/ChangelogToolTests.cs b/tests/CodeIndex.Tests/ChangelogToolTests.cs index adfc4a96c6..0a0f3cf955 100644 --- a/tests/CodeIndex.Tests/ChangelogToolTests.cs +++ b/tests/CodeIndex.Tests/ChangelogToolTests.cs @@ -398,6 +398,35 @@ public void CheckFragmentsRejectsOversizedFragmentBeforeParsing() Assert.Contains($"maximum supported size is {ChangelogTool.MaxFragmentBytes} bytes", ex.Message); } + [Fact] + public void CheckFragmentsRejectsOversizedSymlinkTargetBeforeParsing() + { + using var scope = new TestRepositoryScope(); + scope.WriteFile("CHANGELOG.md", SampleChangelog); + scope.WriteFile("version.json", """ + { + "version": "1.16.0" + } + """); + scope.WriteFile("large-fragment-target.md", OversizedContent(ChangelogTool.MaxFragmentBytes)); + + var linkPath = Path.Combine(scope.Root, "changelog.d", "unreleased", "+large-link.fixed.md"); + Directory.CreateDirectory(Path.GetDirectoryName(linkPath)!); + try + { + File.CreateSymbolicLink(linkPath, Path.Combine(scope.Root, "large-fragment-target.md")); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or PlatformNotSupportedException) + { + return; + } + + var tool = new ChangelogTool(scope.Root); + var thrown = Assert.Throws(() => tool.CheckFragments()); + Assert.Contains("changelog.d/unreleased/+large-link.fixed.md: file is larger than", thrown.Message); + Assert.Contains($"maximum supported size is {ChangelogTool.MaxFragmentBytes} bytes", thrown.Message); + } + [Fact] public void PrepareRejectsOversizedChangelogBeforeParsing() { diff --git a/tools/CodeIndex.Changelog/Program.cs b/tools/CodeIndex.Changelog/Program.cs index 0f537974d6..ec3b14f74e 100644 --- a/tools/CodeIndex.Changelog/Program.cs +++ b/tools/CodeIndex.Changelog/Program.cs @@ -557,7 +557,7 @@ private static string ReadAllTextBounded(string absolutePath, string repositoryR { var fileInfo = new FileInfo(absolutePath); var length = fileInfo.Length; - if (length > maxBytes) + if (fileInfo.LinkTarget is null && length > maxBytes) { var relativePath = string.IsNullOrWhiteSpace(repositoryRoot) ? Path.GetFileName(absolutePath) @@ -565,7 +565,34 @@ private static string ReadAllTextBounded(string absolutePath, string repositoryR throw new ChangelogException($"{relativePath}: file is {length} bytes; maximum supported size is {maxBytes} bytes."); } - return File.ReadAllText(absolutePath); + using var stream = File.Open(absolutePath, FileMode.Open, FileAccess.Read, FileShare.Read); + using var memory = new MemoryStream(); + var buffer = new byte[8192]; + var totalBytesRead = 0L; + + while (true) + { + var remainingBytes = maxBytes + 1 - totalBytesRead; + var readLength = (int)Math.Min(buffer.Length, remainingBytes); + var bytesRead = stream.Read(buffer.AsSpan(0, readLength)); + if (bytesRead == 0) + break; + + totalBytesRead += bytesRead; + if (totalBytesRead > maxBytes) + { + var relativePath = string.IsNullOrWhiteSpace(repositoryRoot) + ? Path.GetFileName(absolutePath) + : Path.GetRelativePath(repositoryRoot, absolutePath).Replace('\\', '/'); + throw new ChangelogException($"{relativePath}: file is larger than {maxBytes} bytes; maximum supported size is {maxBytes} bytes."); + } + + memory.Write(buffer.AsSpan(0, bytesRead)); + } + + memory.Position = 0; + using var reader = new StreamReader(memory, Encoding.UTF8, detectEncodingFromByteOrderMarks: true); + return reader.ReadToEnd(); } private static void WritePreparedFiles( From 1ec23e29647c0fd35b00c9a751e1f728fadeff76 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Wed, 3 Jun 2026 04:26:30 +0900 Subject: [PATCH 8/8] Preserve changelog symlink targets (#2910) --- changelog.d/unreleased/2910.fixed.md | 4 +-- tests/CodeIndex.Tests/ChangelogToolTests.cs | 37 +++++++++++++++++++++ tools/CodeIndex.Changelog/Program.cs | 37 +++++++++++++++++---- 3 files changed, 70 insertions(+), 8 deletions(-) diff --git a/changelog.d/unreleased/2910.fixed.md b/changelog.d/unreleased/2910.fixed.md index 7b5ea32c50..ece18efb7a 100644 --- a/changelog.d/unreleased/2910.fixed.md +++ b/changelog.d/unreleased/2910.fixed.md @@ -10,8 +10,8 @@ affected: ## English -- **Changelog release preparation now stages writes before deleting fragments (#2910)** — `prepare` writes temporary changelog and version files, deletes partial temp files when staged writes fail, rolls release files back when staging or replacement fails before fragment deletion starts, and deletes consumed fragments only after both release files have been replaced. +- **Changelog release preparation now stages writes before deleting fragments (#2910)** — `prepare` writes temporary changelog and version files, preserves symlinked release-file targets, deletes partial temp files when staged writes fail, rolls release files back when staging or replacement fails before fragment deletion starts, and deletes consumed fragments only after both release files have been replaced. ## 日本語 -- **changelog release preparation が fragment 削除前に書き込みを stage するようになりました (#2910)** — `prepare` は changelog と version の一時ファイルを書き、stage 書き込み失敗時は部分的な temp file を削除し、fragment 削除が始まる前の stage / replacement 失敗時は release file を元へ戻し、両方の release file を置換してから consumed fragment を削除します。 +- **changelog release preparation が fragment 削除前に書き込みを stage するようになりました (#2910)** — `prepare` は changelog と version の一時ファイルを書き、symlink された release file の target を保持し、stage 書き込み失敗時は部分的な temp file を削除し、fragment 削除が始まる前の stage / replacement 失敗時は release file を元へ戻し、両方の release file を置換してから consumed fragment を削除します。 diff --git a/tests/CodeIndex.Tests/ChangelogToolTests.cs b/tests/CodeIndex.Tests/ChangelogToolTests.cs index 0a0f3cf955..afcd0da276 100644 --- a/tests/CodeIndex.Tests/ChangelogToolTests.cs +++ b/tests/CodeIndex.Tests/ChangelogToolTests.cs @@ -77,6 +77,43 @@ public void PrepareMovesFragmentsIntoReleaseAndUpdatesFooter() Assert.False(scope.Exists("changelog.d/unreleased/195.fixed.md")); } + [Fact] + public void PrepareWritesThroughSymlinkedReleaseFiles() + { + using var scope = new TestRepositoryScope(); + scope.WriteFile("actual-changelog.md", SampleChangelog); + scope.WriteFile("actual-version.json", """ + { + "version": "1.16.0" + } + """); + scope.WriteFile("changelog.d/unreleased/195.fixed.md", SampleFragment); + + var changelogLinkPath = Path.Combine(scope.Root, "CHANGELOG.md"); + var versionLinkPath = Path.Combine(scope.Root, "version.json"); + try + { + File.CreateSymbolicLink(changelogLinkPath, "actual-changelog.md"); + File.CreateSymbolicLink(versionLinkPath, "actual-version.json"); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or PlatformNotSupportedException) + { + return; + } + + var tool = new ChangelogTool(scope.Root); + tool.Prepare(new Version(1, 17, 0), new DateOnly(2026, 5, 1), writeChanges: true); + + Assert.NotNull(new FileInfo(changelogLinkPath).LinkTarget); + Assert.NotNull(new FileInfo(versionLinkPath).LinkTarget); + Assert.Contains("English release note", scope.ReadFile("actual-changelog.md")); + Assert.Contains("Japanese release note", scope.ReadFile("actual-changelog.md")); + Assert.Equal(scope.ReadFile("actual-changelog.md"), scope.ReadFile("CHANGELOG.md")); + Assert.Contains("\"version\": \"1.17.0\"", scope.ReadFile("actual-version.json")); + Assert.Equal(scope.ReadFile("actual-version.json"), scope.ReadFile("version.json")); + Assert.False(scope.Exists("changelog.d/unreleased/195.fixed.md")); + } + [Fact] public void PrepareRerunPreservesExistingReleaseAndAppendsNewFragments() { diff --git a/tools/CodeIndex.Changelog/Program.cs b/tools/CodeIndex.Changelog/Program.cs index ec3b14f74e..db5d09d5b1 100644 --- a/tools/CodeIndex.Changelog/Program.cs +++ b/tools/CodeIndex.Changelog/Program.cs @@ -609,20 +609,22 @@ private static void WritePreparedFiles( var changelogReplaced = false; var versionReplaced = false; var fragmentDeletionStarted = false; + var changelogWritePath = ResolveWriteTargetPath(changelogPath); + var versionWritePath = ResolveWriteTargetPath(versionPath); try { - changelogTempPath = WriteStagedText(changelogPath, updatedChangelog); - versionTempPath = WriteStagedText(versionPath, updatedVersionJson); + changelogTempPath = WriteStagedText(changelogWritePath, updatedChangelog); + versionTempPath = WriteStagedText(versionWritePath, updatedVersionJson); NotifyPrepareWritePhase(PrepareWritePhase.StagedFilesWritten); - ReplaceWithStagedFile(changelogTempPath, changelogPath); + ReplaceWithStagedFile(changelogTempPath, changelogWritePath); changelogTempPath = string.Empty; changelogReplaced = true; NotifyPrepareWritePhase(PrepareWritePhase.ChangelogReplaced); - ReplaceWithStagedFile(versionTempPath, versionPath); + ReplaceWithStagedFile(versionTempPath, versionWritePath); versionTempPath = string.Empty; versionReplaced = true; NotifyPrepareWritePhase(PrepareWritePhase.VersionReplaced); @@ -635,10 +637,10 @@ private static void WritePreparedFiles( catch (Exception) when (!fragmentDeletionStarted) { RollBackPreparedFiles( - changelogPath, + changelogWritePath, originalChangelog, changelogReplaced, - versionPath, + versionWritePath, originalVersionJson, versionReplaced); throw; @@ -679,6 +681,29 @@ private static string WriteStagedText(string targetPath, string contents) return tempPath; } + private static string ResolveWriteTargetPath(string targetPath) + { + var fileInfo = new FileInfo(targetPath); + if (fileInfo.LinkTarget is null) + return targetPath; + + var finalTarget = fileInfo.ResolveLinkTarget(returnFinalTarget: true); + if (finalTarget is not null) + return finalTarget.FullName; + + var linkTarget = fileInfo.LinkTarget; + if (string.IsNullOrEmpty(linkTarget)) + return targetPath; + + if (Path.IsPathFullyQualified(linkTarget)) + return linkTarget; + + var directory = fileInfo.DirectoryName; + return Path.GetFullPath(Path.Combine( + string.IsNullOrEmpty(directory) ? Directory.GetCurrentDirectory() : directory, + linkTarget)); + } + private static void ReplaceWithStagedFile(string stagedPath, string targetPath) { File.Move(stagedPath, targetPath, overwrite: true);