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
39 changes: 32 additions & 7 deletions .codex/workflows/release-changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,17 +51,42 @@ 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:
The release workflow publishes a fixed GitHub release-note template instead of
GitHub's generated commit summary. Before creating the release, it asks GitHub
Releases for the latest non-draft, non-prerelease tag, excludes the target tag
itself for idempotent reruns, and passes that existing release version to:

```bash
dotnet run --project tools/CodeIndex.Changelog -- release-notes --version 1.17.0
dotnet run --project tools/CodeIndex.Changelog -- release-notes --version 1.17.0 --previous-version 1.16.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
The command validates that the matching English and 日本語 `### [1.17.0]`
blocks exist in `CHANGELOG.md`, fails if both are empty, and emits:

````md
## What's Changed

Full Changelog: https://github.com/Widthdom/CodeIndex/compare/v1.16.0...v1.17.0

## Install or update

Homebrew:

```bash
brew install widthdom/tap/codeindex
brew upgrade widthdom/tap/codeindex
```

NuGet:

```bash
dotnet tool install -g cdidx
dotnet tool update -g cdidx
```
````

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
Expand Down
23 changes: 21 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -537,12 +537,31 @@ jobs:
- name: Write curated release notes
if: ${{ !inputs.allow_generated_notes }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
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
previous_tag="$(
gh release list \
--exclude-drafts \
--exclude-pre-releases \
--limit 20 \
--json tagName \
--jq ".[] | select(.tagName != \"${TAG_NAME}\") | .tagName" \
| sed -n '1p'
)"
if [ -z "${previous_tag}" ]; then
echo "No previous non-draft, non-prerelease GitHub release was found before ${TAG_NAME}." >&2
exit 1
fi
if [[ ! "${previous_tag}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Latest GitHub release tag is not a v-prefixed SemVer version: ${previous_tag}" >&2
exit 1
fi

previous_version="${previous_tag#v}"
dotnet run --project tools/CodeIndex.Changelog -- release-notes --version "${version}" --previous-version "${previous_version}" > release-notes.md

- name: Create GitHub release
env:
Expand Down
2 changes: 1 addition & 1 deletion TESTING_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ Use the full suite by default. Use targeted filters only while iterating locally
- These test-only packages are separate from the production dependency rule in `src/CodeIndex`, which still allows only `Microsoft.Data.Sqlite` at runtime.
- `FsCheck.Xunit` is reserved for property-based tests that assert universal invariants (never-throws contracts, idempotence, "output is parseable by downstream consumer") across randomly generated inputs. Use it to complement, not replace, the example-based `[Fact]` / `[Theory]` tests — pick FsCheck when the property is a universally quantified claim, and an example test when a specific concrete case is the contract.
- Test parallelism: enabled by default across independent test classes. Tests that touch process-global state such as SQLite pool resets, environment variables, or current-directory overrides must use an explicit non-parallel collection, and tests that swap `Console.Out` / `Console.Error` must lock on `TestConsoleLock.Gate`.
- CI runs the test project through `tests/CodeIndex.Tests/CodeIndex.Tests.runsettings`, enables VSTest blame crash and hang collection, applies a 30-minute session timeout plus 60-second xUnit long-running diagnostics, and reruns the suite once after an initial failure. If the retry passes, CI uploads `TestResults/flaky-retry.txt` with the TRX and blame artifacts so the run is treated as suspect instead of silently trusted.
- CI runs the test project through `tests/CodeIndex.Tests/CodeIndex.Tests.runsettings`, enables VSTest blame crash and hang collection, applies a 45-minute session timeout plus 60-second xUnit long-running diagnostics, and reruns the suite once after an initial failure. If the retry passes, CI uploads `TestResults/flaky-retry.txt` with the TRX and blame artifacts so the run is treated as suspect instead of silently trusted.

## Test Layout

Expand Down
15 changes: 15 additions & 0 deletions changelog.d/unreleased/+ci-test-session-timeout.internal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
category: internal
affected:
- tests/CodeIndex.Tests/CodeIndex.Tests.runsettings
- tests/CodeIndex.Tests/CiWorkflowTests.cs
- TESTING_GUIDE.md
---

## English

- **CI test sessions now allow 45 minutes** — the shared VSTest runsettings timeout now has enough headroom for the Windows net9 full suite when runner variance pushes execution past 30 minutes.

## 日本語

- **CI test session の上限を45分にしました** — Windows net9 の full suite が runner のばらつきで30分を超える場合に備え、共有 VSTest runsettings の timeout に余裕を持たせました。
15 changes: 15 additions & 0 deletions changelog.d/unreleased/+release-notes-template.changed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
category: changed
affected:
- tools/CodeIndex.Changelog/Program.cs
- .github/workflows/release.yml
- .codex/workflows/release-changelog.md
---

## English

- **GitHub release notes now use the install-focused template** — `release-notes` now emits the `What's Changed` compare link plus Homebrew and NuGet install/update commands, using the previous version supplied by the release workflow from the latest non-draft, non-prerelease GitHub Release.

## 日本語

- **GitHub Release notes が install 重視のテンプレートを使うようになりました** — `release-notes` は `What's Changed` の compare link と Homebrew / NuGet の install/update command を出力し、前回リリースバージョンは release workflow が最新の draft / prerelease ではない GitHub Release から渡します。
68 changes: 59 additions & 9 deletions tests/CodeIndex.Tests/ChangelogToolTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -285,7 +285,7 @@ public void PrepareFailureBeforeFragmentDeletionRollsBackReleaseFiles()
}

[Fact]
public void RenderReleaseNotesExtractsMatchingEnglishAndJapaneseSections()
public void RenderReleaseNotesUsesProvidedPreviousVersion()
{
using var scope = new TestRepositoryScope();
scope.WriteFile("CHANGELOG.md", SampleChangelog);
Expand All @@ -299,18 +299,68 @@ public void RenderReleaseNotesExtractsMatchingEnglishAndJapaneseSections()
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));
var notes = tool.RenderReleaseNotes(new Version(1, 17, 0), new Version(1, 16, 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.Equal("""
## What's Changed

Full Changelog: https://github.com/Widthdom/CodeIndex/compare/v1.16.0...v1.17.0

## Install or update

Homebrew:

```bash
brew install widthdom/tap/codeindex
brew upgrade widthdom/tap/codeindex
```

NuGet:

```bash
dotnet tool install -g cdidx
dotnet tool update -g cdidx
```
""".Replace("\r\n", "\n") + "\n", notes.Replace("\r\n", "\n"));
Assert.DoesNotContain("English release note", notes);
Assert.DoesNotContain("Japanese release note", notes);
Assert.DoesNotContain("[Unreleased]:", notes);
}

[Fact]
public void RenderReleaseNotesDoesNotRequireTargetCompareFooter()
{
using var scope = new TestRepositoryScope();
scope.WriteFile("CHANGELOG.md", """
# Changelog

All notable changes to this project will be documented in this file.

## English

### [1.17.0] - 2026-05-01

#### Fixed
- English release note.

## 日本語

### [1.17.0] - 2026-05-01

#### 修正
- Japanese release note.

[Unreleased]: https://github.com/Widthdom/CodeIndex/compare/v1.17.0...HEAD
[1.16.0]: https://github.com/Widthdom/CodeIndex/compare/v1.15.3...v1.16.0
""");

var tool = new ChangelogTool(scope.Root);
var notes = tool.RenderReleaseNotes(new Version(1, 17, 0), new Version(1, 16, 0));

Assert.Contains("Full Changelog: https://github.com/Widthdom/CodeIndex/compare/v1.16.0...v1.17.0", notes);
Assert.DoesNotContain("CHANGELOG.md is missing compare-link footer", notes);
}

[Fact]
public void CheckFragmentsRejectsCategoryMismatch()
{
Expand Down
2 changes: 1 addition & 1 deletion tests/CodeIndex.Tests/CiWorkflowTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ public void Runsettings_DefinesSessionTimeoutAndXunitLongRunningDiagnostics()
var document = XDocument.Load(path);

Assert.Equal(
"1800000",
"2700000",
document.Root?.Element("RunConfiguration")?.Element("TestSessionTimeout")?.Value);
Assert.Equal(
"60",
Expand Down
2 changes: 1 addition & 1 deletion tests/CodeIndex.Tests/CodeIndex.Tests.runsettings
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
<RunSettings>
<RunConfiguration>
<ResultsDirectory>./TestResults</ResultsDirectory>
<TestSessionTimeout>1800000</TestSessionTimeout>
<TestSessionTimeout>2700000</TestSessionTimeout>
</RunConfiguration>
<xUnit>
<LongRunningTestSeconds>60</LongRunningTestSeconds>
Expand Down
18 changes: 18 additions & 0 deletions tests/CodeIndex.Tests/ReleaseWorkflowTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,24 @@ public void ReleaseWorkflow_ValidatesNuGetVersionBeforePublishing()
Assert.DoesNotContain("--skip-duplicate", workflow);
}

[Fact]
public void ReleaseWorkflow_UsesChangelogToolForTemplatedReleaseNotes()
{
var workflow = File.ReadAllText(Path.Combine(GetRepositoryRoot(), ".github", "workflows", "release.yml"));

Assert.Contains("gh release list", workflow);
Assert.Contains("--exclude-drafts", workflow);
Assert.Contains("--exclude-pre-releases", workflow);
Assert.Contains("select(.tagName != \\\"${TAG_NAME}\\\")", workflow);
Assert.Contains("No previous non-draft, non-prerelease GitHub release was found", workflow);
Assert.Contains("Latest GitHub release tag is not a v-prefixed SemVer version", workflow);
Assert.Contains("dotnet run --project tools/CodeIndex.Changelog -- release-notes", workflow);
Assert.Contains("--previous-version \"${previous_version}\"", workflow);
Assert.Contains("--notes-file release-notes.md", workflow);
Assert.Contains("--notes-file release-install-notes.md", workflow);
Assert.DoesNotContain("cat release-install-notes.md >> release-notes.md", workflow);
}

// Issue #2756: NuGet emits the core-properties OPC part with a random
// *.psmdcp entry name, so two otherwise identical pack runs can produce
// different .nupkg/.snupkg bytes. The release workflow normalizes that
Expand Down
54 changes: 40 additions & 14 deletions tools/CodeIndex.Changelog/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,8 @@ public static int Main(string[] args)
}
case "release-notes":
{
var options = ParseOptions(args[1..], requireDate: false);
Console.Out.Write(tool.RenderReleaseNotes(options.Version));
var options = ParseOptions(args[1..], requireDate: false, requirePreviousVersion: true);
Console.Out.Write(tool.RenderReleaseNotes(options.Version, options.PreviousVersion!));
return 0;
}
default:
Expand All @@ -68,7 +68,7 @@ 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");
Console.Out.WriteLine(" dotnet run --project tools/CodeIndex.Changelog -- release-notes --version Y.Y.Y --previous-version X.X.X");
Console.Out.WriteLine();
Console.Out.WriteLine("Limits:");
Console.Out.WriteLine($" unreleased fragments: {ChangelogTool.MaxFragmentCount}");
Expand All @@ -77,9 +77,10 @@ private static void PrintUsage()
Console.Out.WriteLine($" version.json size: {ChangelogTool.MaxVersionJsonBytes} bytes");
}

private static ParsedOptions ParseOptions(string[] args, bool requireDate)
private static ParsedOptions ParseOptions(string[] args, bool requireDate, bool requirePreviousVersion = false)
{
Version? version = null;
Version? previousVersion = null;
DateOnly? releaseDate = null;

for (var i = 0; i < args.Length; i++)
Expand All @@ -103,6 +104,15 @@ private static ParsedOptions ParseOptions(string[] args, bool requireDate)
continue;
}

if (arg == "--previous-version")
{
if (i + 1 >= args.Length)
throw new ChangelogException("Missing value for --previous-version.");

previousVersion = Version.Parse(args[++i]);
continue;
}

throw new ChangelogException($"Unknown option '{arg}'.");
}

Expand All @@ -112,7 +122,10 @@ private static ParsedOptions ParseOptions(string[] args, bool requireDate)
if (requireDate && releaseDate is null)
throw new ChangelogException("Missing required option --date.");

return new ParsedOptions(version, releaseDate ?? default);
if (requirePreviousVersion && previousVersion is null)
throw new ChangelogException("Missing required option --previous-version.");

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

private static string FindRepositoryRoot()
Expand Down Expand Up @@ -147,7 +160,7 @@ private static string FindRepositoryRoot()
return null;
}

private sealed record ParsedOptions(Version Version, DateOnly ReleaseDate);
private sealed record ParsedOptions(Version Version, DateOnly ReleaseDate, Version? PreviousVersion);
}

public sealed class ChangelogTool
Expand Down Expand Up @@ -283,8 +296,11 @@ public PrepareResult Prepare(Version targetVersion, DateOnly releaseDate, bool w
return new PrepareResult(summary.ToString().TrimEnd(), writeChanges ? null : updatedChangelog);
}

public string RenderReleaseNotes(Version targetVersion)
public string RenderReleaseNotes(Version targetVersion, Version previousVersion)
{
if (previousVersion.CompareTo(targetVersion) >= 0)
throw new ChangelogException($"Previous version v{previousVersion} must be older than target version v{targetVersion}.");

var changelogPath = Path.Combine(_repositoryRoot, "CHANGELOG.md");
var changelogText = ReadAllTextBounded(changelogPath, _repositoryRoot, MaxChangelogBytes).Replace("\r\n", "\n", StringComparison.Ordinal);
var changelog = ParsedChangelog.Parse(changelogText);
Expand All @@ -300,16 +316,26 @@ public string RenderReleaseNotes(Version targetVersion)

var output = new List<string>
{
$"## CodeIndex v{targetVersion}",
"## What's Changed",
string.Empty,
$"Full Changelog: https://github.com/Widthdom/CodeIndex/compare/v{previousVersion}...v{targetVersion}",
string.Empty,
"## Install or update",
string.Empty,
"Homebrew:",
string.Empty,
"```bash",
"brew install widthdom/tap/codeindex",
"brew upgrade widthdom/tap/codeindex",
"```",
string.Empty,
"### English",
"NuGet:",
string.Empty,
"```bash",
"dotnet tool install -g cdidx",
"dotnet tool update -g cdidx",
"```",
};
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;
}
Expand Down
Loading