Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .codex/workflows/changelog-fragment.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 8388608 bytes, and `version.json` at most 16384 bytes.

## Template

```md
Expand Down
17 changes: 17 additions & 0 deletions changelog.d/unreleased/2909.fixed.md
Original file line number Diff line number Diff line change
@@ -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 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 が各 command の読み取る入力に上限を適用するようになりました (#2909)** — `check` は未リリース fragment 数と fragment file size、`prepare` はそれに加えて `CHANGELOG.md` と `version.json`、`release-notes` は `CHANGELOG.md` が過大な場合に parse 前に拒否します。
17 changes: 17 additions & 0 deletions changelog.d/unreleased/2910.fixed.md
Original file line number Diff line number Diff line change
@@ -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, 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 の一時ファイルを書き、symlink された release file の target を保持し、stage 書き込み失敗時は部分的な temp file を削除し、fragment 削除が始まる前の stage / replacement 失敗時は release file を元へ戻し、両方の release file を置換してから consumed fragment を削除します。
284 changes: 284 additions & 0 deletions tests/CodeIndex.Tests/ChangelogToolTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down Expand Up @@ -127,6 +164,126 @@ 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<ChangelogException>(() => 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 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<ChangelogException>(() => 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()
{
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<ChangelogException>(() => 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.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 RenderReleaseNotesExtractsMatchingEnglishAndJapaneseSections()
{
Expand Down Expand Up @@ -240,6 +397,114 @@ 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<ChangelogException>(() => 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<ChangelogException>(() => 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 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<ChangelogException>(() => 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()
{
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<ChangelogException>(() => 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 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()
{
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<ChangelogException>(() => 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;
Expand All @@ -253,6 +518,25 @@ private static int CountOccurrences(string text, string value)
return count;
}

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;
Expand Down
4 changes: 4 additions & 0 deletions tools/CodeIndex.Changelog/CodeIndex.Changelog.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,8 @@
<IsPackable>false</IsPackable>
</PropertyGroup>

<ItemGroup>
<InternalsVisibleTo Include="CodeIndex.Tests" />
</ItemGroup>

</Project>
Loading
Loading