From e5e5252814210f3034a2d047935e55dfba9d1ed1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 20 Oct 2026 11:32:32 +0200 Subject: [PATCH] build: each package's release notes are its version's changelog section The ReleaseNotesFromChangelog target writes the section under '## $(Version)' of CHANGELOG.md into every nuspec, as the plain text nuget.org shows (measured on package pages: it escapes the notes and links bare URLs, nothing else), with one line linking the whole changelog. Every package carries the whole section: the areas name topics, not packages, and every satellite depends on the base package. A missing or empty section, a relative link, or notes over 35,000 characters fail the pack, naming the file. PackageReleaseNotesTests holds the packed nuspecs to the section, and runs the target on a sample changelog of every Markdown shape and of MSBuild-special text, in LF and CRLF, and on the four it must refuse. Closes #75 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 + CONTRIBUTING.md | 7 +- Directory.Build.props | 1 - src/Directory.Build.targets | 59 ++++++ .../PackageReleaseNotesTests.cs | 176 ++++++++++++++++++ 5 files changed, 244 insertions(+), 3 deletions(-) create mode 100644 test/CodoMetis.TypeKit.Conventions.Tests/PackageReleaseNotesTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 19cb7e37..747b6d6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8f94af09..6953ae8f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 `. Their ids and severities are public contract from then on. `ChangelogTests` fails until steps 1 and 2 agree. @@ -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 `## — Unreleased` section. `ChangelogTests` fails while the baseline trails the last - version the changelog dates. Once that is on `main`, cut the next `release/` 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/` from it and push it. `workflow_dispatch` with `dry_run` (the default) rehearses everything but the push. diff --git a/Directory.Build.props b/Directory.Build.props index 3f40888e..4883433f 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -4,7 +4,6 @@ CaffeinatedCoder Copyright (c) 2026 CaffeinatedCoder MIT - https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/CHANGELOG.md https://github.com/CaffeinatedCoder/CodoMetis.TypeKit https://github.com/CaffeinatedCoder/CodoMetis.TypeKit git diff --git a/src/Directory.Build.targets b/src/Directory.Build.targets index 0ba5aac4..ed2ddcbe 100644 --- a/src/Directory.Build.targets +++ b/src/Directory.Build.targets @@ -17,4 +17,63 @@ + + + + + $([MSBuild]::GetDirectoryNameOfFileAbove('$(MSBuildThisFileDirectory)', 'CodoMetis.TypeKit.slnx'))/CHANGELOG.md + + + + + + <_TypeKitChangelogText>$([System.Text.RegularExpressions.Regex]::Replace($([System.IO.File]::ReadAllText('$(ReleaseNotesChangelog)')), '\r', '')) + <_TypeKitHeading>^## $([System.Text.RegularExpressions.Regex]::Escape('$(Version)'))(?: [^\n]*)?\n + <_TypeKitHasSection>$([System.Text.RegularExpressions.Regex]::IsMatch('$(_TypeKitChangelogText)', '(?m)$(_TypeKitHeading)')) + + + + + + <_TypeKitSection>$([System.Text.RegularExpressions.Regex]::Replace('$(_TypeKitChangelogText)', '(?sm)\A.*?$(_TypeKitHeading)(.*?)(?=^## |\z).*\z', '$1').Trim()) + <_TypeKitRelativeLink>$([System.Text.RegularExpressions.Regex]::IsMatch('$(_TypeKitSection)', '\]\((?!https?://)')) + + + + + + + <_TypeKitNotes>$([System.Text.RegularExpressions.Regex]::Replace('$(_TypeKitSection)', '\[([^\]\n]+)\]\(([^)\s]+)\)', '$1 ($2)')) + <_TypeKitNotes>$([System.Text.RegularExpressions.Regex]::Replace('$(_TypeKitNotes)', '(?m)^#{3,6} +', '')) + <_TypeKitNotes>$(_TypeKitNotes.Replace('`', '').Replace('**', '')) + $(_TypeKitNotes)%0A%0AFull changelog: $(PackageProjectUrl)/blob/main/CHANGELOG.md + <_TypeKitNotesLength>$([MSBuild]::Unescape('$(PackageReleaseNotes)').Length) + + + + diff --git a/test/CodoMetis.TypeKit.Conventions.Tests/PackageReleaseNotesTests.cs b/test/CodoMetis.TypeKit.Conventions.Tests/PackageReleaseNotesTests.cs new file mode 100644 index 00000000..74d9386b --- /dev/null +++ b/test/CodoMetis.TypeKit.Conventions.Tests/PackageReleaseNotesTests.cs @@ -0,0 +1,176 @@ +using System.Diagnostics; +using System.Text.RegularExpressions; +using System.Xml.Linq; + +namespace CodoMetis.TypeKit.Conventions.Tests; + +/// +/// 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). +/// +/// +/// nuget.org's Release Notes tab and Visual Studio's Updates tab show the nuspec's +/// releaseNotes, frozen with the version once pushed. The ReleaseNotesFromChangelog +/// target in src/Directory.Build.targets 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. +/// +/// Plain text, measured on nuget.org on 2026-10-20: release notes are escaped, each line break becomes +/// <br />, and a bare URL is linked; the brackets of [text](url), backticks, ** +/// and # 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). +/// +/// +[Collection(PacksCollection.Name)] +public sealed partial class PackageReleaseNotesTests(AnalyzerPackagingTests.Packs packs) +{ + public static TheoryData 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."); + + /// A section between two others, with every shape the changelog writes, and text MSBuild would expand or unescape. + 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" & 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" & 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 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."); + } + + /// The section, read line by line from the line after its heading up to the next ## , in plain text, with the footer. + 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 in its nuspec."); + return notes.Value; + } + + /// + /// The target alone, on one shipping project, against a changelog of the test's own: what it sets + /// PackageReleaseNotes to, or its refusal. Nothing is built or written beside the project. + /// + 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(); + + /// Inline code, emphasis, a link, or a heading marker at the start of a line. + [GeneratedRegex(@"(?m)`|\*\*|\]\(|^#")] + private static partial Regex Markdown(); +}