Skip to content
Open
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 CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ section is the GitHub release's notes.

## 2.3.0 — Unreleased

### Changed

- Packages: nuget.org's Release Notes tab and Visual Studio's Updates tab show the version's section of this changelog, as plain text, where they showed a link to the file.

## 2.2.0 — 2026-10-18

### Fixed
Expand Down
7 changes: 5 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,9 @@ The packages share one version and are released together, only through
`.github/workflows/release.yml` (see [SECURITY.md](SECURITY.md)).

1. In `CHANGELOG.md`, replace `Unreleased` in the version's heading with today's date
(`## 1.0.0 — 2026-10-01`). The section becomes the GitHub release's notes.
(`## 1.0.0 — 2026-10-01`). The section becomes the GitHub release's notes, and every package's
release notes on nuget.org: pack writes it into each nuspec as plain text, and fails while the
section is missing, empty or over nuget.org's 35,000 characters (`PackageReleaseNotesTests`).
2. Move the rules in `src/CodoMetis.TypeKit.Analyzers/AnalyzerReleases.Unshipped.md` to
`AnalyzerReleases.Shipped.md`, under `## Release <version>`. Their ids and severities are public
contract from then on. `ChangelogTests` fails until steps 1 and 2 agree.
Expand All @@ -128,7 +130,8 @@ The packages share one version and are released together, only through
4. Afterwards: raise `Version` in `Directory.Build.props`, set `PackageValidationBaselineVersion` to
the version just released, so ApiCompat guards the public surface from then on, and open a new
`## <version> — Unreleased` section. `ChangelogTests` fails while the baseline trails the last
version the changelog dates. Once that is on `main`, cut the next `release/<version>` from it and
version the changelog dates, and pack while the new section is empty, so open it with its first
line. Once that is on `main`, cut the next `release/<version>` from it and
push it.

`workflow_dispatch` with `dry_run` (the default) rehearses everything but the push.
Expand Down
1 change: 0 additions & 1 deletion Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
<Authors>CaffeinatedCoder</Authors>
<Copyright>Copyright (c) 2026 CaffeinatedCoder</Copyright>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<PackageReleaseNotes>https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/CHANGELOG.md</PackageReleaseNotes>
<PackageProjectUrl>https://github.com/CaffeinatedCoder/CodoMetis.TypeKit</PackageProjectUrl>
<RepositoryUrl>https://github.com/CaffeinatedCoder/CodoMetis.TypeKit</RepositoryUrl>
<RepositoryType>git</RepositoryType>
Expand Down
59 changes: 59 additions & 0 deletions src/Directory.Build.targets
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,63 @@
<ProjectReference Include="$(MSBuildThisFileDirectory)CodoMetis.TypeKit.Analyzers/CodoMetis.TypeKit.Analyzers.csproj"
OutputItemType="Analyzer" ReferenceOutputAssembly="false" PrivateAssets="all" />
</ItemGroup>

<!--
Release notes: the version's section of CHANGELOG.md, which nuget.org shows on its Release Notes
tab and Visual Studio on its Updates tab, followed by one line linking the whole changelog (#75).

Every package carries the whole section. The packages share one version and one changelog, every
satellite depends on the base package and through it on the analyzers, and a line's area names a
topic ("OpenAPI", "JSON"), not a package, so filtering by area would hide from a satellite's
reader a change that reaches them through the base package.

Plain text, because nuget.org shows release notes as plain text: measured 2026-10-20, it escapes
them, turns each line break into <br />, and links a bare URL, leaving the brackets of [text](url)
and every # * and backtick on the page (Bogus 35.6.5, Asp.Versioning.Http 10.2.3), and a URL in
parentheses linked without its closing one (RazorEngine 3.10.0). So [text](url) is written
text (url), and backticks, ** and the ### of a subheading are dropped.

Fails closed: a missing or empty section, a relative link (nothing on nuget.org resolves it), or
notes over nuget.org's 35,000 characters fail the pack, naming the file. The section of an
Unreleased version is read too, so a CI pack carries the notes of the commit it was built from.
PackageReleaseNotesTests holds every packed nuspec to this, and runs the target on a sample
changelog of every shape (ReleaseNotesChangelog) whatever the current section holds.
-->
<Target Name="ReleaseNotesFromChangelog" BeforeTargets="GenerateNuspec">
<PropertyGroup>
<ReleaseNotesChangelog Condition="'$(ReleaseNotesChangelog)' == ''">$([MSBuild]::GetDirectoryNameOfFileAbove('$(MSBuildThisFileDirectory)', 'CodoMetis.TypeKit.slnx'))/CHANGELOG.md</ReleaseNotesChangelog>
</PropertyGroup>

<Error Condition="!Exists('$(ReleaseNotesChangelog)')" Text="There is no $(ReleaseNotesChangelog) to take $(PackageId)'s release notes from." />

<PropertyGroup>
<_TypeKitChangelogText>$([System.Text.RegularExpressions.Regex]::Replace($([System.IO.File]::ReadAllText('$(ReleaseNotesChangelog)')), '\r', ''))</_TypeKitChangelogText>
<_TypeKitHeading>^## $([System.Text.RegularExpressions.Regex]::Escape('$(Version)'))(?: [^\n]*)?\n</_TypeKitHeading>
<_TypeKitHasSection>$([System.Text.RegularExpressions.Regex]::IsMatch('$(_TypeKitChangelogText)', '(?m)$(_TypeKitHeading)'))</_TypeKitHasSection>
</PropertyGroup>

<Error Condition="'$(_TypeKitHasSection)' != 'True'" File="$(ReleaseNotesChangelog)"
Text="CHANGELOG.md has no '## $(Version)' section, so $(PackageId) $(Version) would have no release notes. Add the section (CONTRIBUTING.md, Releasing)." />

<PropertyGroup>
<_TypeKitSection>$([System.Text.RegularExpressions.Regex]::Replace('$(_TypeKitChangelogText)', '(?sm)\A.*?$(_TypeKitHeading)(.*?)(?=^## |\z).*\z', '$1').Trim())</_TypeKitSection>
<_TypeKitRelativeLink>$([System.Text.RegularExpressions.Regex]::IsMatch('$(_TypeKitSection)', '\]\((?!https?://)'))</_TypeKitRelativeLink>
</PropertyGroup>

<Error Condition="'$(_TypeKitSection)' == ''" File="$(ReleaseNotesChangelog)"
Text="CHANGELOG.md's '## $(Version)' section is empty, so $(PackageId) $(Version) would have no release notes. Write the version's changes under it." />
<Error Condition="'$(_TypeKitRelativeLink)' == 'True'" File="$(ReleaseNotesChangelog)"
Text="CHANGELOG.md's '## $(Version)' section has a relative link, which nuget.org cannot resolve in the release notes. Link the absolute URL on GitHub." />

<PropertyGroup>
<_TypeKitNotes>$([System.Text.RegularExpressions.Regex]::Replace('$(_TypeKitSection)', '\[([^\]\n]+)\]\(([^)\s]+)\)', '$1 ($2)'))</_TypeKitNotes>
<_TypeKitNotes>$([System.Text.RegularExpressions.Regex]::Replace('$(_TypeKitNotes)', '(?m)^#{3,6} +', ''))</_TypeKitNotes>
<_TypeKitNotes>$(_TypeKitNotes.Replace('`', '').Replace('**', ''))</_TypeKitNotes>
<PackageReleaseNotes>$(_TypeKitNotes)%0A%0AFull changelog: $(PackageProjectUrl)/blob/main/CHANGELOG.md</PackageReleaseNotes>
<_TypeKitNotesLength>$([MSBuild]::Unescape('$(PackageReleaseNotes)').Length)</_TypeKitNotesLength>
</PropertyGroup>

<Error Condition="$(_TypeKitNotesLength) &gt; 35000" File="$(ReleaseNotesChangelog)"
Text="$(PackageId) $(Version)'s release notes, from CHANGELOG.md's '## $(Version)' section, are $(_TypeKitNotesLength) characters, over nuget.org's 35,000. Shorten the section's lines: their details belong in the docs they link." />
</Target>
</Project>
176 changes: 176 additions & 0 deletions test/CodoMetis.TypeKit.Conventions.Tests/PackageReleaseNotesTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
using System.Diagnostics;
using System.Text.RegularExpressions;
using System.Xml.Linq;

namespace CodoMetis.TypeKit.Conventions.Tests;

/// <summary>
/// Every package's release notes are its version's section of CHANGELOG.md, in the plain text
/// nuget.org shows, followed by one line linking the whole changelog (#75).
/// </summary>
/// <remarks>
/// nuget.org's Release Notes tab and Visual Studio's Updates tab show the nuspec's
/// <c>releaseNotes</c>, frozen with the version once pushed. The <c>ReleaseNotesFromChangelog</c>
/// target in <c>src/Directory.Build.targets</c> writes it at pack. These read the packed nuspecs and
/// derive the expected text again from the changelog, line by line rather than by the target's
/// expressions, so a target that reads another version's section or stops running fails here. A
/// missing or empty section fails the pack itself, and with it the fixture. The version's section
/// may hold no Markdown at all, which would let a target that leaves Markdown in pass, so the target
/// also runs on a sample changelog of every shape, and on samples it must refuse.
/// <para>
/// Plain text, measured on nuget.org on 2026-10-20: release notes are escaped, each line break becomes
/// <c>&lt;br /&gt;</c>, and a bare URL is linked; the brackets of <c>[text](url)</c>, backticks, <c>**</c>
/// and <c>#</c> reach the page literally (Bogus 35.6.5, Asp.Versioning.Http 10.2.3), and a URL in
/// parentheses is linked without its closing one (RazorEngine 3.10.0).
/// </para>
/// </remarks>
[Collection(PacksCollection.Name)]
public sealed partial class PackageReleaseNotesTests(AnalyzerPackagingTests.Packs packs)
{
public static TheoryData<string> ShippingPackages => [.. Repository.ShippingProjects()];

private static XElement Props => XDocument.Load(Path.Combine(Repository.Root, "Directory.Build.props")).Root!;

private static string Version => Props.Descendants("Version").Single().Value.Trim();

private static string Footer => $"Full changelog: {Props.Descendants("PackageProjectUrl").Single().Value.Trim()}/blob/main/CHANGELOG.md";

[Theory]
[MemberData(nameof(ShippingPackages))]
public void The_release_notes_are_the_versions_changelog_section(string package) =>
ReleaseNotes(package).ShouldBe(Expected(File.ReadAllText(Path.Combine(Repository.Root, "CHANGELOG.md")), Version),
$"{package}'s release notes are not CHANGELOG.md's '## {Version}' section in plain text. The ReleaseNotesFromChangelog target (src/Directory.Build.targets) writes them at pack.");

[Theory]
[MemberData(nameof(ShippingPackages))]
public void The_release_notes_carry_no_markdown(string package) =>
Markdown().Matches(ReleaseNotes(package)).Select(match => match.Value).ShouldBeEmpty(
$"{package}'s release notes contain Markdown, which nuget.org shows literally.");

/// <summary>A section between two others, with every shape the changelog writes, and text MSBuild would expand or unescape.</summary>
private const string Sample = """
# Changelog

## 9.9.10 — Unreleased

- Later: not this version.

## 9.9.9 — 2026-01-02

### Fixed

- Generators: `Parse` on a **span** ([What is generated](https://example.org/g#what)).
- [`Code` in a link](https://example.org/c?a=1&b=2) and 50%3B; $(Foo) @(Bar) %(Baz) 'q' "d" <a> & b — ü.

## 9.9.8 — 2026-01-01

- Earlier: not this version.
""";

private const string SamplePlain = """
Fixed

- Generators: Parse on a span (What is generated (https://example.org/g#what)).
- Code in a link (https://example.org/c?a=1&b=2) and 50%3B; $(Foo) @(Bar) %(Baz) 'q' "d" <a> & b — ü.
""";

[Theory]
[InlineData("\n")]
[InlineData("\r\n")]
public async Task The_target_writes_every_shape_as_plain_text(string lineEnding)
{
var changelog = Sample.ReplaceLineEndings(lineEnding);
var expected = $"{SamplePlain.ReplaceLineEndings("\n")}\n\n{Footer}";

Expected(changelog, "9.9.9").ShouldBe(expected, "This test's own reading of a section disagrees with the sample's plain text.");

var (exitCode, output) = await RunTarget(changelog, "9.9.9");

exitCode.ShouldBe(0, output);
output.ReplaceLineEndings("\n").Trim().ShouldBe(expected);
}

public static TheoryData<string, string, string> Refused => new()
{
{ "no section", "## 9.9.8 — 2026-01-01\n\n- Earlier.\n", "has no '## 9.9.9' section" },
{ "empty section", "## 9.9.9 — Unreleased\n\n## 9.9.8 — 2026-01-01\n\n- Earlier.\n", "'## 9.9.9' section is empty" },
{ "relative link", "## 9.9.9 — Unreleased\n\n- See [the docs](docs/json.md).\n", "relative link" },
{ "too long", $"## 9.9.9 — Unreleased\n\n- {new string('x', 35_000)}\n", "over nuget.org's 35,000" },
};

[Theory]
[MemberData(nameof(Refused))]
public async Task The_target_refuses_a_section_nuget_org_cannot_show(string what, string changelog, string message)
{
var (exitCode, output) = await RunTarget(changelog, "9.9.9");

exitCode.ShouldNotBe(0, $"The target accepted a changelog with {what}:{Environment.NewLine}{output}");
output.ShouldContain(message, Case.Sensitive, $"The target refused a changelog with {what}, but not for that reason:{Environment.NewLine}{output}");
output.ShouldContain("CHANGELOG.md", Case.Sensitive, "The refusal does not name the changelog.");
}

/// <summary>The section, read line by line from the line after its heading up to the next <c>## </c>, in plain text, with the footer.</summary>
private static string Expected(string changelog, string version)
{
var lines = changelog.ReplaceLineEndings("\n").Split('\n');
var heading = Array.FindIndex(lines, line => line == $"## {version}" || line.StartsWith($"## {version} ", StringComparison.Ordinal));

heading.ShouldBeGreaterThanOrEqualTo(0, $"CHANGELOG.md has no '## {version}' section.");

var section = string.Join('\n', lines.Skip(heading + 1).TakeWhile(line => !line.StartsWith("## ", StringComparison.Ordinal))).Trim();
section.ShouldNotBeEmpty($"CHANGELOG.md's '## {version}' section is empty.");

var plain = section.Split('\n').Select(line =>
Link().Replace(line.StartsWith("### ", StringComparison.Ordinal) ? line[4..] : line, "$1 ($2)").Replace("`", "").Replace("**", ""));

return $"{string.Join('\n', plain)}\n\n{Footer}";
}

private string ReleaseNotes(string package)
{
var notes = packs.Nuspec(package).Descendants()
.SingleOrDefault(element => element.Name.LocalName == "releaseNotes" && element.Parent?.Name.LocalName == "metadata");

notes.ShouldNotBeNull($"{package} has no <releaseNotes> in its nuspec.");
return notes.Value;
}

/// <summary>
/// The target alone, on one shipping project, against a changelog of the test's own: what it sets
/// <c>PackageReleaseNotes</c> to, or its refusal. Nothing is built or written beside the project.
/// </summary>
private static async Task<(int ExitCode, string Output)> RunTarget(string changelog, string version)
{
var directory = Directory.CreateTempSubdirectory("codometis-typekit-release-notes-");
try
{
var path = Path.Combine(directory.FullName, "CHANGELOG.md");
await File.WriteAllTextAsync(path, changelog);

var project = Path.Combine(Repository.Root, "src", "CodoMetis.TypeKit.Serilog", "CodoMetis.TypeKit.Serilog.csproj");
using var process = Process.Start(new ProcessStartInfo("dotnet",
["msbuild", project, "-t:ReleaseNotesFromChangelog", "-getProperty:PackageReleaseNotes", $"-p:ReleaseNotesChangelog={path}", $"-p:Version={version}", "-nodeReuse:false"])
{
RedirectStandardOutput = true,
RedirectStandardError = true,
})!;

var output = process.StandardOutput.ReadToEndAsync();
var errors = process.StandardError.ReadToEndAsync();
await process.WaitForExitAsync();

return (process.ExitCode, await output + await errors);
}
finally
{
directory.Delete(recursive: true);
}
}

[GeneratedRegex(@"\[([^\]\n]+)\]\((https?://[^)\s]+)\)")]
private static partial Regex Link();

/// <summary>Inline code, emphasis, a link, or a heading marker at the start of a line.</summary>
[GeneratedRegex(@"(?m)`|\*\*|\]\(|^#")]
private static partial Regex Markdown();
}
Loading