diff --git a/.codex/workflows/changelog-fragment.md b/.codex/workflows/changelog-fragment.md index 2920a3f713..588985ad59 100644 --- a/.codex/workflows/changelog-fragment.md +++ b/.codex/workflows/changelog-fragment.md @@ -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 diff --git a/.codex/workflows/precommit.md b/.codex/workflows/precommit.md index ab78fa89b7..b7a2209d9c 100644 --- a/.codex/workflows/precommit.md +++ b/.codex/workflows/precommit.md @@ -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 #...`. diff --git a/.codex/workflows/release-changelog.md b/.codex/workflows/release-changelog.md index 0fdeed074b..73013556c8 100644 --- a/.codex/workflows/release-changelog.md +++ b/.codex/workflows/release-changelog.md @@ -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: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 128c2305c6..8b65035ced 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 @@ -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 @@ -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 diff --git a/changelog.d/README.md b/changelog.d/README.md index f8c9b903db..a3781cb8c4 100644 --- a/changelog.d/README.md +++ b/changelog.d/README.md @@ -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: diff --git a/changelog.d/unreleased/1849.fixed.md b/changelog.d/unreleased/1849.fixed.md new file mode 100644 index 0000000000..93a0bc06c6 --- /dev/null +++ b/changelog.d/unreleased/1849.fixed.md @@ -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 のみに限定しました。 diff --git a/changelog.d/unreleased/1999.docs.md b/changelog.d/unreleased/1999.docs.md new file mode 100644 index 0000000000..18564d74d6 --- /dev/null +++ b/changelog.d/unreleased/1999.docs.md @@ -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` の失敗例も明示するようになりました。 diff --git a/tests/CodeIndex.Tests/ChangelogToolTests.cs b/tests/CodeIndex.Tests/ChangelogToolTests.cs index 3895831d92..f889a93fc7 100644 --- a/tests/CodeIndex.Tests/ChangelogToolTests.cs +++ b/tests/CodeIndex.Tests/ChangelogToolTests.cs @@ -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() { @@ -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(() => tool.CheckFragments()); + Assert.Contains("invalid issue number 'null'", ex.Message); + } + [Fact] public void CheckFragmentsRejectsMissingJapaneseSection() { diff --git a/tools/CodeIndex.Changelog/Program.cs b/tools/CodeIndex.Changelog/Program.cs index 3d6479e14d..6d23569c00 100644 --- a/tools/CodeIndex.Changelog/Program.cs +++ b/tools/CodeIndex.Changelog/Program.cs @@ -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}'."); } @@ -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; @@ -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() @@ -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 + { + $"## 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 LoadFragments(bool validate) { var fragmentDirectory = Path.Combine(_repositoryRoot, "changelog.d", "unreleased");