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
13 changes: 13 additions & 0 deletions .codex/workflows/changelog-fragment.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,19 @@ Treat any request to "update the changelog" as a fragment request unless the tas
7. Do not include release headings or compare-link footer definitions.
8. Validate fragments before committing.

## Validation

Run the fragment validator before committing any changelog fragment:

```bash
dotnet run --project tools/CodeIndex.Changelog -- check
```

The command validates every `changelog.d/unreleased/*.md` fragment and fails
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.

## Template

```md
Expand Down
1 change: 1 addition & 0 deletions .codex/workflows/precommit.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ Run this before each commit.
5. If public behavior, CLI/MCP output, install/release behavior, or workflow behavior changed, confirm matching docs and changelog updates are present in the same change unless you can clearly justify why they are unnecessary.
6. If documentation changed, confirm it matches implementation and covers the user-facing contract accurately.
7. If a changelog update is required, confirm it is a valid bilingual fragment under `changelog.d/unreleased/` unless the task is explicitly a release-preparation change.
- Run `dotnet run --project tools/CodeIndex.Changelog -- check` whenever changelog fragments were added or edited. This catches malformed front matter such as `issues: null` or `issues: []`.
8. Fail the precommit check if ordinary implementation work edited `CHANGELOG.md` without a release-preparation reason. Direct `CHANGELOG.md` edits are not the normal response to a changelog request.
9. If docs or changelog were intentionally omitted for a user-visible change, stop and fix that before committing.
10. Confirm issue auto-close references will be placed in the PR body as `Fixes #...`.
Expand Down
24 changes: 24 additions & 0 deletions .codex/workflows/release-changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,30 @@ version. The NuGet publish job validates that the pushed `v*` tag exactly
matches `version.json` before packing, so do not tag a release from a commit
where `version.json` still contains the previous version.

Fragment validation can also be run independently:

```bash
dotnet run --project tools/CodeIndex.Changelog -- check
```

Run this before committing release-preparation changes if you edited or
received new fragments after the last `prepare` run.

## GitHub release notes

The release workflow publishes the curated `CHANGELOG.md` section for the tag,
not GitHub's generated commit summary. Before creating the release it runs:

```bash
dotnet run --project tools/CodeIndex.Changelog -- release-notes --version 1.17.0
```

The command extracts the matching English and 日本語 `### [1.17.0]` blocks from
`CHANGELOG.md` and fails if either section is missing or both are empty. This
means the release-preparation PR must land before the `v*` tag is pushed. The
workflow keeps GitHub-generated notes only as an explicit
`workflow_dispatch` fallback via `allow_generated_notes`.

## Compare-link footer

For a release from `1.16.0` to `1.17.0`, the footer must change from:
Expand Down
23 changes: 22 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ on:
description: Existing v* tag to release
required: true
type: string
allow_generated_notes:
description: Use GitHub auto-generated release notes instead of the curated CHANGELOG section
required: false
default: false
type: boolean

permissions:
contents: write
Expand Down Expand Up @@ -344,10 +349,21 @@ jobs:
```
EOF

- name: Write curated release notes
if: ${{ !inputs.allow_generated_notes }}
env:
TAG_NAME: ${{ inputs.tag_name || github.ref_name }}
run: |
set -euo pipefail
version="${TAG_NAME#v}"
dotnet run --project tools/CodeIndex.Changelog -- release-notes --version "${version}" > release-notes.md
cat release-install-notes.md >> release-notes.md

- name: Create GitHub release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG_NAME: ${{ inputs.tag_name || github.ref_name }}
USE_GENERATED_NOTES: ${{ inputs.allow_generated_notes || false }}
run: |
set -euo pipefail
# Idempotent on workflow re-runs: if a transient failure in a later
Expand All @@ -366,12 +382,17 @@ jobs:
if gh release view "${TAG_NAME}" >/dev/null 2>&1; then
echo "Release ${TAG_NAME} already exists; uploading missing assets with --clobber."
gh release upload "${TAG_NAME}" release-files/* --clobber
else
elif [ "${USE_GENERATED_NOTES}" = "true" ]; then
gh release create "${TAG_NAME}" \
release-files/* \
--verify-tag \
--notes-file release-install-notes.md \
--generate-notes
else
gh release create "${TAG_NAME}" \
release-files/* \
--verify-tag \
--notes-file release-notes.md
fi

- name: Wait for release assets to be downloadable
Expand Down
12 changes: 12 additions & 0 deletions changelog.d/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,18 @@ issue number.
If the fragment is not tied to an issue, omit `issues` from the front matter
entirely; do not write `issues: null` or `issues: []`.

## Validation

Validate fragments locally before committing:

```bash
dotnet run --project tools/CodeIndex.Changelog -- check
```

The validator reads every `changelog.d/unreleased/*.md` fragment and reports the
fragment path with the failed rule. For example, `issues: null` is rejected as
an invalid issue number; omit `issues` entirely for non-issue fragments.

## Categories

Allowed categories:
Expand Down
18 changes: 18 additions & 0 deletions changelog.d/unreleased/1849.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
category: fixed
issues:
- 1849
affected:
- .github/workflows/release.yml
- .codex/workflows/release-changelog.md
- tools/CodeIndex.Changelog/Program.cs
- tests/CodeIndex.Tests/ChangelogToolTests.cs
---

## English

- **GitHub releases now use curated changelog notes (#1849)** - the release workflow renders the matching `CHANGELOG.md` section into the GitHub release body and keeps generated notes only as an explicit manual fallback.

## 日本語

- **GitHub release が curated changelog notes を使うようになりました (#1849)** - release workflow は該当する `CHANGELOG.md` セクションを GitHub release 本文に出力し、generated notes は明示的な手動 fallback のみに限定しました。
18 changes: 18 additions & 0 deletions changelog.d/unreleased/1999.docs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
category: docs
issues:
- 1999
affected:
- .codex/workflows/changelog-fragment.md
- .codex/workflows/precommit.md
- .codex/workflows/release-changelog.md
- changelog.d/README.md
---

## English

- **Documented changelog fragment validation (#1999)** - the fragment, release, and precommit workflows now point agents to `dotnet run --project tools/CodeIndex.Changelog -- check`, including the failure mode for `issues: null`.

## 日本語

- **changelog fragment の検証手順を文書化しました (#1999)** - fragment / release / precommit workflow が `dotnet run --project tools/CodeIndex.Changelog -- check` を案内し、`issues: null` の失敗例も明示するようになりました。
57 changes: 57 additions & 0 deletions tests/CodeIndex.Tests/ChangelogToolTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,33 @@ 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 RenderReleaseNotesExtractsMatchingEnglishAndJapaneseSections()
{
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);
tool.Prepare(new Version(1, 17, 0), new DateOnly(2026, 5, 1), writeChanges: true);

var notes = tool.RenderReleaseNotes(new Version(1, 17, 0));

Assert.StartsWith("## CodeIndex v1.17.0", notes, StringComparison.Ordinal);
Assert.Contains("### English", notes);
Assert.Contains("English release note", notes);
Assert.Contains("Existing English unreleased note", notes);
Assert.Contains("### 日本語", notes);
Assert.Contains("Japanese release note", notes);
Assert.Contains("Existing Japanese unreleased note", notes);
Assert.DoesNotContain("[Unreleased]:", notes);
}

[Fact]
public void CheckFragmentsRejectsCategoryMismatch()
{
Expand Down Expand Up @@ -157,6 +184,36 @@ public void CheckFragmentsRejectsCategoryMismatch()
Assert.Contains("file name category 'fixed' does not match front matter category 'changed'", ex.Message);
}

[Fact]
public void CheckFragmentsRejectsNonIssueFragmentWithNullIssues()
{
using var scope = new TestRepositoryScope();
scope.WriteFile("CHANGELOG.md", SampleChangelog);
scope.WriteFile("version.json", """
{
"version": "1.16.0"
}
""");
scope.WriteFile("changelog.d/unreleased/+bad.docs.md", """
---
category: docs
issues: null
---

## English

- **Bad fragment** — invalid issues field.

## 日本語

- **Bad fragment** — invalid issues field.
""");

var tool = new ChangelogTool(scope.Root);
var ex = Assert.Throws<ChangelogException>(() => tool.CheckFragments());
Assert.Contains("invalid issue number 'null'", ex.Message);
}

[Fact]
public void CheckFragmentsRejectsMissingJapaneseSection()
{
Expand Down
48 changes: 43 additions & 5 deletions tools/CodeIndex.Changelog/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,18 +31,24 @@ public static int Main(string[] args)
}
case "prepare":
{
var options = ParseOptions(args[1..]);
var options = ParseOptions(args[1..], requireDate: true);
var result = tool.Prepare(options.Version, options.ReleaseDate, writeChanges: true);
Console.Out.WriteLine(result.Summary);
return 0;
}
case "render":
{
var options = ParseOptions(args[1..]);
var options = ParseOptions(args[1..], requireDate: true);
var result = tool.Prepare(options.Version, options.ReleaseDate, writeChanges: false);
Console.Out.Write(result.RenderedChangelog ?? string.Empty);
return 0;
}
case "release-notes":
{
var options = ParseOptions(args[1..], requireDate: false);
Console.Out.Write(tool.RenderReleaseNotes(options.Version));
return 0;
}
default:
throw new ChangelogException($"Unknown command '{command}'.");
}
Expand All @@ -62,9 +68,10 @@ private static void PrintUsage()
Console.Out.WriteLine(" dotnet run --project tools/CodeIndex.Changelog -- check");
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");
}

private static ParsedOptions ParseOptions(string[] args)
private static ParsedOptions ParseOptions(string[] args, bool requireDate)
{
Version? version = null;
DateOnly? releaseDate = null;
Expand Down Expand Up @@ -96,10 +103,10 @@ private static ParsedOptions ParseOptions(string[] args)
if (version is null)
throw new ChangelogException("Missing required option --version.");

if (releaseDate is null)
if (requireDate && releaseDate is null)
throw new ChangelogException("Missing required option --date.");

return new ParsedOptions(version, releaseDate.Value);
return new ParsedOptions(version, releaseDate ?? default);
}

private static string FindRepositoryRoot()
Expand Down Expand Up @@ -258,6 +265,37 @@ public PrepareResult Prepare(Version targetVersion, DateOnly releaseDate, bool w
return new PrepareResult(summary.ToString().TrimEnd(), writeChanges ? null : updatedChangelog);
}

public string RenderReleaseNotes(Version targetVersion)
{
var changelogPath = Path.Combine(_repositoryRoot, "CHANGELOG.md");
var changelogText = File.ReadAllText(changelogPath).Replace("\r\n", "\n", StringComparison.Ordinal);
var changelog = ParsedChangelog.Parse(changelogText);
var versionPrefix = $"### [{targetVersion}]";

var englishBlock = changelog.EnglishBlocks.FirstOrDefault(block => block.HeadingLine.StartsWith(versionPrefix, StringComparison.Ordinal));
var japaneseBlock = changelog.JapaneseBlocks.FirstOrDefault(block => block.HeadingLine.StartsWith(versionPrefix, StringComparison.Ordinal));
if (englishBlock is null || japaneseBlock is null)
throw new ChangelogException($"CHANGELOG.md is missing release notes for v{targetVersion}.");

if (englishBlock.BodyLines.Count == 0 && japaneseBlock.BodyLines.Count == 0)
throw new ChangelogException($"CHANGELOG.md release notes for v{targetVersion} are empty.");

var output = new List<string>
{
$"## CodeIndex v{targetVersion}",
string.Empty,
"### English",
string.Empty,
};
output.AddRange(englishBlock.BodyLines);
output.Add(string.Empty);
output.Add("### 日本語");
output.Add(string.Empty);
output.AddRange(japaneseBlock.BodyLines);

return string.Join('\n', output).TrimEnd() + Environment.NewLine;
}

private List<Fragment> LoadFragments(bool validate)
{
var fragmentDirectory = Path.Combine(_repositoryRoot, "changelog.d", "unreleased");
Expand Down
Loading