From 360ef083c86d5b8c5fcb49664c3cd18a1015010e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 20 Oct 2026 11:31:22 +0200 Subject: [PATCH] chore: write each package's changelog section into its NuGet release notes PackageReleaseNotes linked blob/main/src//CHANGELOG.md, so nuget.org's Release Notes tab and Visual Studio's Updates tab sent a reader of any version to today's file. Pack now writes the version's section of the package's own changelog (Directory.Build.targets, before GenerateNuspec), in plain text because nuget.org renders release notes as typed and links only bare URLs, with a footer linking the changelog at the version's tag. Pack fails naming the file on a missing, empty, doubled or over-35,000-character section. PackagingConventionTests generates every package's nuspec through NuGet's pack task and compares releaseNotes with the section; the smoke test checks the .nupkg itself. Refs #346 Co-Authored-By: Claude Opus 5.5 (cherry picked from commit a86ae011fc16b32c11ea029e0ed84a44507e11bf) --- AGENTS.md | 12 +- CONTRIBUTING.md | 6 +- Directory.Build.props | 6 +- Directory.Build.targets | 118 ++++++++++++ docs/testing.md | 2 +- .../PackagingConventionTests.cs | 181 +++++++++++++++--- test/consumer-smoke-test.sh | 38 ++++ 7 files changed, 330 insertions(+), 33 deletions(-) create mode 100644 Directory.Build.targets diff --git a/AGENTS.md b/AGENTS.md index 389f25b..6578a4c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,10 +27,10 @@ so moving a project cannot silently retarget a test. - `test/CodoMetis.ValueRanges.EFCore.PostgreSQL.InfinityDisabled.IntegrationTests/` — The same live layer for configuration that is per process: its runtimeconfig sets `Npgsql.DisableDateTimeInfinityConversions`, which Npgsql and the plugin each read once, so it cannot be flipped inside the other project's run. Holds the temporal store rule at the extremes with the conversion off. Own container; CI's integration job runs every `test/*IntegrationTests/` - `test/Directory.Build.targets` — Floats the EF test projects (every project whose name contains `EFCore`) to a newer EF Core and Npgsql EF patch for one run, `-p:EfFloat=10.0.13 -p:NpgsqlEfFloat=10.0.3`, while `src/` keeps compiling against the floor it ships: a consumer's situation, which floor-only CI missed for #302. Without the properties it does nothing. CI's newest-patch integration legs pass them, and the newest published versions live in one place, the `integration-legs` matrix in `dotnet.yml` (#297) - `test/CodoMetis.ValueRanges.AspNetCore.Tests/` — Host-level behaviour with no ValueRanges registration: configuration binding (`TypeConverter`), MVC and minimal-API binding and JSON bodies via `TestServer`, and the documented OpenAPI schema transformer (`ValueRangesSchemaTransformer.cs`), which `docs/serialization.md` must carry verbatim — edit the test copy, then the doc. `HostCultureProbeTests` binds every discovered type (core and NodaTime) through both hosts under four request cultures; it links `RangeProbes`, `SetProbes`, `WrapperElements` and `Reflect` from the conventions project rather than copying them -- `test/CodoMetis.ValueRanges.Conventions.Tests/` — Repo-level conventions: changelog consistency and form, packaging metadata, release wiring, the SECURITY.md supported-versions table, value set contract compliance, EF mapping parity, the default JSON contract. Everything is discovered (projects by globbing `src/`, types by reflection), so adding a package or a type needs no edit here +- `test/CodoMetis.ValueRanges.Conventions.Tests/` — Repo-level conventions: changelog consistency and form, packaging metadata (each package's nuspec, generated by NuGet's own pack task, carries its version's changelog section as release notes), release wiring, the SECURITY.md supported-versions table, value set contract compliance, EF mapping parity, the default JSON contract. Everything is discovered (projects by globbing `src/`, types by reflection), so adding a package or a type needs no edit here - `test/oracles/` — The bug-finding system (`docs/oracles.md`): `claims.json` quotes every promise the consumer-facing docs make and names the test that holds it (empty `heldBy` is the worklist); `oracles.json` registers the sweep classes; `seeds/*.patch` plant one defect each in `src/` that the named oracles were shown to catch; `surface-waivers.json` names the public members no claim covers, with the reason; `candidates.json` holds disagreements found but not fixed, keyed to issues; `postgres-surface.json` holds every PostgreSQL range, multirange and array operator and function (one entry per row of `postgres-surface.catalog.txt`, the raw `pg_operator`/`pg_proc` listing) with the member that mirrors it, the translation a test asserts, or a waiver or issue; `CloneSweep.cs` is the clone-divergence sweep over the hand-copied type families (sets, arities, ranges, EF registrations), and `clone-waivers.json` explains every divergence it finds between siblings that is right as it is. `Oracle*Tests` and `CloneDivergenceTests` in the conventions project hold all of it consistent in CI without building; `dotnet run tools/oracles.cs seeds` re-proves locally that every seed is caught, `… status` prints the worklist, `… pg-surface` regenerates the listing from a container, `… clones` prints the sibling divergences with their waiver status - `tools/oracles.cs` — The local prover and status report for `test/oracles/`; file-based app, never run in CI -- `test/consumer-smoke-test.sh` — The only check of *delivery* rather than code: packs the four packages, restores them into throwaway projects created outside the repository (inside it, `Directory.Build.props` would apply), compiles and runs real code against them, and asserts on printed output and translated SQL — never on the exit code alone. Four consumers: core only; a Native AOT publish of core + NodaTime, run as a native binary (extra linker paths via `SMOKE_AOT_LINKER_ARGS`, e.g. for Homebrew's dotnet; `SMOKE_AOT_SDK` pins that consumer to another installed SDK through a `global.json` beside it and targets its framework, which is how the .NET 11 SDK's ILCompiler is run over the net10.0 packages by hand (#299) until #297 gives the 10.x CI a leg for it); the NodaTime EF satellite, which pulls in all four through the nuspec chain; and the EF package beside `EFCore.ComplexIndexes.PostgreSQL` from nuget.org, asserting its exclusion and temporal constraints over this package's columns in the create script. `NUGET_PACKAGES` is redirected to a private folder because NuGet resolves id+version from the global cache before any source, so a locally built package is otherwise shadowed by whatever build of that version was restored before; the generated `nuget.config` uses package source mapping so this repo's ids come from the local feed only and everything else from nuget.org. Runs on every PR in both modes (own feed; the pre-built `dist` the release workflow passes). Everything is discovered (projects by globbing `src/`, types by reflection), so adding a package or a type needs no edit here +- `test/consumer-smoke-test.sh` — The only check of *delivery* rather than code: packs the four packages, restores them into throwaway projects created outside the repository (inside it, `Directory.Build.props` would apply), compiles and runs real code against them, and asserts on printed output and translated SQL — never on the exit code alone. Before the consumers it reads each `.nupkg`'s nuspec: the release notes open with the version's changelog section in plain text and end with the changelog at the version's tag. Four consumers: core only; a Native AOT publish of core + NodaTime, run as a native binary (extra linker paths via `SMOKE_AOT_LINKER_ARGS`, e.g. for Homebrew's dotnet; `SMOKE_AOT_SDK` pins that consumer to another installed SDK through a `global.json` beside it and targets its framework, which is how the .NET 11 SDK's ILCompiler is run over the net10.0 packages by hand (#299) until #297 gives the 10.x CI a leg for it); the NodaTime EF satellite, which pulls in all four through the nuspec chain; and the EF package beside `EFCore.ComplexIndexes.PostgreSQL` from nuget.org, asserting its exclusion and temporal constraints over this package's columns in the create script. `NUGET_PACKAGES` is redirected to a private folder because NuGet resolves id+version from the global cache before any source, so a locally built package is otherwise shadowed by whatever build of that version was restored before; the generated `nuget.config` uses package source mapping so this repo's ids come from the local feed only and everything else from nuget.org. Runs on every PR in both modes (own feed; the pre-built `dist` the release workflow passes). Everything is discovered (projects by globbing `src/`, types by reflection), so adding a package or a type needs no edit here - `docs/` — Agent docs (read relevant doc before starting work) ## Commands @@ -47,6 +47,14 @@ surface of the shipping projects — the shipped `.xml` is what IntelliSense sho internal-API usage (EF1001) is acknowledged with a file-level pragma next to the reason, never repo-wide. +Pack writes each package's release notes (nuget.org's Release Notes tab, Visual Studio's Updates +tab) from the `## [$(Version)]` section of its own `src//CHANGELOG.md`, through the target +in `Directory.Build.targets`. They are plain text, because nuget.org shows release notes as typed +and links only bare URLs: a link becomes `text (url)`, and backticks, bold and `###` are dropped. +Pack fails, naming the file, when the section is missing, empty, doubled or over nuget.org's +35,000 characters, so a version bump needs its section in all four package changelogs before +`dotnet pack` runs. Never set `PackageReleaseNotes` anywhere else. + ## Workflow 1. Read `docs/architecture.md` before modifying range types or interfaces 2. Explore the codebase — range operations are per-shape (Finite, UnboundedStart, etc.) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 12a4988..43a3e12 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -199,7 +199,9 @@ fails outright. A version's section of the root `CHANGELOG.md` is the GitHub release's notes, word for word, so it is written for someone deciding what to change before they upgrade. The package changelogs use the -same form, with the entries that affect that package. +same form, with the entries that affect that package, and a package's section is its release notes +on nuget.org and in Visual Studio: pack writes it into the nuspec in plain text, because nuget.org +shows no Markdown, and fails when the section is missing or empty (`Directory.Build.targets`). - **The heading** is `## [x.y.z] — yyyy-mm-dd`. Write entries under the version being prepared, never under `Unreleased`. The date is the tag's: set it in all five changelogs in the commit that @@ -218,7 +220,7 @@ same form, with the entries that affect that package. where it helps a reader skim (`JSON:`, `Literals:`, `EF Core:`, `NodaTime:`). In `Added`, lead with the new member in bold. - **Keep each section in area order:** `Ranges`, `Value sets`, `Literals`, `JSON`, `ASP.NET Core`, - `EF Core`, `NodaTime`, `Docs`, so a reader finds an area's lines together. In `Added`, the core + `EF Core`, `NodaTime`, `Docs`, `Packaging`, so a reader finds an area's lines together. In `Added`, the core members come first, then `EF Core:`, then `Docs:`. Use no headings below the three sections. - **End with a link** to the guide that explains it. Links are absolute (`https://github.com/CaffeinatedCoder/CodoMetis.ValueRanges/blob/main/docs/…`), because the diff --git a/Directory.Build.props b/Directory.Build.props index c57a0ca..df7db51 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -8,10 +8,8 @@ https://github.com/CaffeinatedCoder/CodoMetis.ValueRanges https://github.com/CaffeinatedCoder/CodoMetis.ValueRanges git - - https://github.com/CaffeinatedCoder/CodoMetis.ValueRanges/blob/main/src/$(MSBuildProjectName)/CHANGELOG.md + + + $(MSBuildProjectDirectory)/CHANGELOG.md + + + + + + <_ReleaseNotesChangelogPath>$([System.IO.Path]::GetRelativePath('$(MSBuildThisFileDirectory)', '$(ReleaseNotesChangelog)').Replace('\', '/')) + + + + + + + + + + + + + + + + + + + + + + + + heading.IsMatch(lines[index])).ToList(); + + if (starts.Count != 1) + { + Log.LogError( + starts.Count == 0 + ? "{0} has no '## [{1}]' section, so the package has no release notes for {1}. Add the section, or \"No change in this package.\" under it." + : "{0} has more than one '## [{1}]' section; the release notes would be ambiguous.", + Changelog, Version); + return false; + } + + var section = lines.Skip(starts[0] + 1) + .TakeWhile(line => !line.StartsWith("## ", StringComparison.Ordinal)) + .Select(line => line.TrimEnd()) + .SkipWhile(line => line.Length == 0) + .Reverse().SkipWhile(line => line.Length == 0).Reverse() + .ToList(); + + if (section.Count == 0) + { + Log.LogError("{0}'s '## [{1}]' section is empty, so the package would ship with no release notes. Say \"No change in this package.\" if that is the case.", Changelog, Version); + return false; + } + + var link = new Regex(@"\[(?[^\[\]]+)\]\((?https?://[^\s()]+)\)"); + + ReleaseNotes = string.Join("\n", section.Select(line => + { + if (line.StartsWith("### ", StringComparison.Ordinal)) line = line.Substring(4); + line = link.Replace(line, "${text} (${url})"); + return line.Replace("`", "").Replace("**", ""); + })) + + "\n\nFull changelog: " + FullChangelogUrl; + + if (ReleaseNotes.Length > NuGetLimit) + { + Log.LogError("The release notes from {0}'s '## [{1}]' section are {2} characters; nuget.org accepts at most {3}.", Changelog, Version, ReleaseNotes.Length, NuGetLimit); + return false; + } + ]]> + + + diff --git a/docs/testing.md b/docs/testing.md index 4263769..526bffb 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -91,7 +91,7 @@ Three further parity sweeps close the gaps the 8.0.0 audit left, each the struct `UnionCanonicalityTests.cs` (Id block 12xxx) and the second half of `ExecuteUpdateJsonTests.cs` (96xx) hold the two refusals `ValueSetSqlGuard` makes on the final SQL. Every refusal is asserted by *executing* the query — the 8.0.0 projection test only called `ToQueryString()` and asserted nothing, which is how a comparison that answered `false` against a live server was documented as working. The union data is the probe that found the gaps (`{a,c}` and `{b,c}`, each unioned with `{a,b}`: equal in memory, different concatenations on the server), and the positive half executes every legal composition and compares with memory. Several shapes compose over a `UNION ALL` (`Concatenated`) on purpose: EF inlines a projected union into a later predicate wherever it can, so only a derived table EF cannot see through proves that the guard follows a column back to its `array_cat` — disabling that tracking fails exactly those tests. The JSON half uses `UpdateGuardRow`, whose complex type is mapped both inside a document and as plain columns, so a refusal keyed off the CLR type rather than the translated setter would fail `TheSameComplexTypeOutsideAJsonColumn_IsStillUpdatable`. -`test/CodoMetis.ValueRanges.Conventions.Tests/` covers what the compiler cannot: defects that build, test and pack clean and only surface once a consumer installs the package. Five groups — **changelog consistency** (the shipped `Version` is documented at the root and in every package's changelog, no file runs ahead of the version property, per-package versions are a subset of the root's, sections newest-first), **packaging** (every package declares a `PackageReadmeFile` and packs a README from *its own* directory rather than a shared one, carries a description and tags; the shared props ship symbols and Source Link with `ContinuousIntegrationBuild` gated on CI), **value set contracts** (the `NormalizeElement`, `CanonicalOrder` and `ElementJsonConverter` rules from AGENTS.md, asserted behaviourally: a set that breaks any of them fails to find an element it was built from, or writes objects where scalars belong), and **mapping parity** (every range and set type in both shipping assemblies resolves through `IRelationalTypeMappingSource` — including each wrapper arity closed over its representative element, which is a second way to be missing from the registry, since the families are registered by open generic definition rather than as closed definitions; plain arrays keep their native provider mapping), and **the default JSON contract** (`JsonDefaultContractTests`: every range shape — through the union, through `object`, inside a `RangeSet`, as a dictionary key — and every set family serializes unregistered exactly as under `AddRangeConverters()`, which is what fails when a new type or variant lacks its own `[JsonConverter]`). The **probe sweeps** (`*ProbeTests`, one `[DynamicData]` row per discovered type) hold the same promises over the product of consumer settings listed by hand in `test/oracles/axes.md`: `JsonOptionsProbeTests` runs every type through six declarations (union, sealed variant, interface, `object`, record property, dictionary value) under six `JsonSerializerOptions` (default, camelCase, snake_case, `ReferenceHandler.Preserve`, a source-generated context, `NumberHandling.AllowReadingFromString`) in under a second, and hands its disagreements to `Candidates.Verdict` scoped to the row. `CultureEntryPointProbeTests` runs every type, through the union and the sealed variant, through every entry point that needs no host — `Parse` and `TryParse` (string and span, a null and the current-culture provider), `TypeDescriptor` both ways with a null and the current culture, configuration binding, JSON (value and dictionary key) and interpolation — under de-DE, sv-SE, fa-IR and th-TH as the *current* culture, against the reference read and written under the invariant culture. `LiteralProviderContractTests` varies only the provider and `LiteralCultureContractTests` only `ToString`, so a parser that reached for `CultureInfo.CurrentCulture` itself passed both on a machine set to the invariant culture; its seed, `parser-reads-the-current-culture`, is that defect. +`test/CodoMetis.ValueRanges.Conventions.Tests/` covers what the compiler cannot: defects that build, test and pack clean and only surface once a consumer installs the package. Five groups — **changelog consistency** (the shipped `Version` is documented at the root and in every package's changelog, no file runs ahead of the version property, per-package versions are a subset of the root's, sections newest-first), **packaging** (every package declares a `PackageReadmeFile` and packs a README from *its own* directory rather than a shared one, carries a description and tags, and ships its version's changelog section as release notes, read from a nuspec NuGet's pack task generates and compared with the section, with pack failing on a missing, empty, doubled or oversized one; the shared props ship symbols and Source Link with `ContinuousIntegrationBuild` gated on CI), **value set contracts** (the `NormalizeElement`, `CanonicalOrder` and `ElementJsonConverter` rules from AGENTS.md, asserted behaviourally: a set that breaks any of them fails to find an element it was built from, or writes objects where scalars belong), and **mapping parity** (every range and set type in both shipping assemblies resolves through `IRelationalTypeMappingSource` — including each wrapper arity closed over its representative element, which is a second way to be missing from the registry, since the families are registered by open generic definition rather than as closed definitions; plain arrays keep their native provider mapping), and **the default JSON contract** (`JsonDefaultContractTests`: every range shape — through the union, through `object`, inside a `RangeSet`, as a dictionary key — and every set family serializes unregistered exactly as under `AddRangeConverters()`, which is what fails when a new type or variant lacks its own `[JsonConverter]`). The **probe sweeps** (`*ProbeTests`, one `[DynamicData]` row per discovered type) hold the same promises over the product of consumer settings listed by hand in `test/oracles/axes.md`: `JsonOptionsProbeTests` runs every type through six declarations (union, sealed variant, interface, `object`, record property, dictionary value) under six `JsonSerializerOptions` (default, camelCase, snake_case, `ReferenceHandler.Preserve`, a source-generated context, `NumberHandling.AllowReadingFromString`) in under a second, and hands its disagreements to `Candidates.Verdict` scoped to the row. `CultureEntryPointProbeTests` runs every type, through the union and the sealed variant, through every entry point that needs no host — `Parse` and `TryParse` (string and span, a null and the current-culture provider), `TypeDescriptor` both ways with a null and the current culture, configuration binding, JSON (value and dictionary key) and interpolation — under de-DE, sv-SE, fa-IR and th-TH as the *current* culture, against the reference read and written under the invariant culture. `LiteralProviderContractTests` varies only the provider and `LiteralCultureContractTests` only `ToString`, so a parser that reached for `CultureInfo.CurrentCulture` itself passed both on a machine set to the invariant culture; its seed, `parser-reads-the-current-culture`, is that defect. Everything is discovered — projects by globbing `src/`, types by reflection — so a new package or type is covered without editing the tests. Two consequences worth knowing: diff --git a/test/CodoMetis.ValueRanges.Conventions.Tests/PackagingConventionTests.cs b/test/CodoMetis.ValueRanges.Conventions.Tests/PackagingConventionTests.cs index 40366b1..2ce9708 100644 --- a/test/CodoMetis.ValueRanges.Conventions.Tests/PackagingConventionTests.cs +++ b/test/CodoMetis.ValueRanges.Conventions.Tests/PackagingConventionTests.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using System.Text.RegularExpressions; using System.Xml.Linq; @@ -246,42 +247,174 @@ public void PackageValidationBaseline_IsTheShippedVersionOrTheReleaseBeforeIt() + "since the old baseline goes unvalidated."); } + private const string RepositoryBlob = "https://github.com/CaffeinatedCoder/CodoMetis.ValueRanges/blob/"; + /// - /// Every package's release notes link to its own changelog, and that file exists. + /// Every package's nuspec carries its version's section of its own CHANGELOG.md as release + /// notes, as plain text, with one footer line linking that changelog at the version's tag. /// /// - /// The link is set once in Directory.Build.props from $(MSBuildProjectName), so it - /// is right only while each project sits in a directory of its own name under src/, and - /// only while no project sets its own. A renamed directory or an override would point the NuGet - /// page's "Release notes" at a 404, which nothing else checks. + /// nuget.org's Release Notes tab and Visual Studio's Updates tab show this field, frozen per + /// version. The nuspec is generated by NuGet's own pack task, through the target in + /// Directory.Build.targets, with no build output (none is needed for the metadata), so + /// this checks what pack writes and not what a property says. The expected text is derived here + /// from the changelog independently of that target. nuget.org renders the field as plain text + /// (newlines become line breaks, bare URLs become links, nothing else is interpreted), so the + /// Markdown is gone: a link reads text (url), and no backtick, bold marker or heading + /// marker is left over. /// [TestMethod] - public void EveryPackage_LinksItsReleaseNotesToItsOwnChangelog() + public void EveryPackage_ShipsItsVersionsChangelogSectionAsReleaseNotes() { - const string prefix = "https://github.com/CaffeinatedCoder/CodoMetis.ValueRanges/blob/main/"; + foreach (var project in RepoLayout.PackableProjects) + { + var (exit, output, nuspec) = GenerateNuspec(project); - var notes = SharedBuildProperties().GetValueOrDefault("PackageReleaseNotes"); + Assert.AreEqual(0, exit, $"Packing {project.PackageId}'s nuspec failed:\n{output}"); - Assert.IsNotNull(notes, "Directory.Build.props sets no PackageReleaseNotes, so the NuGet page links no release notes."); - Assert.IsTrue( - notes.StartsWith(prefix, StringComparison.Ordinal), - $"PackageReleaseNotes is '{notes}'; expected a link under {prefix}."); + var notes = nuspec!.Descendants().SingleOrDefault(element => element.Name.LocalName == "releaseNotes")?.Value; - foreach (var project in RepoLayout.PackableProjects) + Assert.IsNotNull(notes, $"{project.PackageId}'s nuspec has no release notes; nothing set PackageReleaseNotes before it was written."); + Assert.AreEqual( + ExpectedReleaseNotes(project), notes, + $"{project.PackageId}'s nuspec does not carry the '## [{RepoLayout.ShippedVersion}]' section of " + + $"{RepoPath(ChangelogOf(project))} as its release notes."); + + foreach (var marker in new[] { "](", "`", "**" }) + Assert.IsFalse( + notes.Contains(marker, StringComparison.Ordinal), + $"{project.PackageId}'s release notes hold the Markdown '{marker}', which nuget.org shows as typed."); + Assert.IsFalse( + notes.Split('\n').Any(line => line.StartsWith('#')), + $"{project.PackageId}'s release notes hold a Markdown heading, which nuget.org shows as typed."); + } + } + + /// + /// Pack fails, naming the changelog, when the version's section is missing, empty or doubled, or + /// when the notes exceed nuget.org's 35,000 characters, rather than shipping a package with no + /// release notes or with some other version's. + /// + /// + /// ChangelogConventionTests requires the section in every package changelog, so this + /// cannot happen in the repository as it is. The rule is checked on a changelog written to a + /// temporary file, handed to the target through its ReleaseNotesChangelog property. + /// + [TestMethod] + [DataRow("missing", "has no '## [{0}]' section")] + [DataRow("empty", "section is empty")] + [DataRow("doubled", "more than one '## [{0}]' section")] + [DataRow("too long", "nuget.org accepts at most 35000")] + public void Pack_FailsNamingTheChangelog_WhenTheSectionCannotBeTheReleaseNotes(string defect, string expected) + { + var version = RepoLayout.ShippedVersion; + var heading = $"## [{version}] — 2000-01-01"; + var older = "## [0.0.1] — 2000-01-01\n\nThe first release.\n"; + + var text = defect switch { - Assert.IsNull( - project.Property("PackageReleaseNotes"), - $"{project.PackageId} sets its own PackageReleaseNotes; the shared one in Directory.Build.props already links its changelog."); + "missing" => $"# Changelog\n\n{older}", + "empty" => $"# Changelog\n\n{heading}\n\n\n{older}", + "doubled" => $"# Changelog\n\n{heading}\n\nNo change in this package.\n\n{heading}\n\nNo change in this package.\n", + "too long" => $"# Changelog\n\n{heading}\n\n### Fixed\n\n- {new string('x', 35_000)}\n", + _ => throw new ArgumentOutOfRangeException(nameof(defect), defect, null) + }; + + var changelog = Path.Combine(Path.GetTempPath(), $"release-notes-{Guid.NewGuid():N}", "CHANGELOG.md"); + Directory.CreateDirectory(Path.GetDirectoryName(changelog)!); + File.WriteAllText(changelog, text); + + try + { + var (exit, output, _) = GenerateNuspec(RepoLayout.PackableProjects[0], $"-p:ReleaseNotesChangelog={changelog}"); - var name = Path.GetFileNameWithoutExtension(project.File.Name); - var target = notes[prefix.Length..].Replace("$(MSBuildProjectName)", name, StringComparison.Ordinal); - var file = new FileInfo(Path.Combine(RepoLayout.Root.FullName, target)); + Assert.AreNotEqual(0, exit, $"Pack succeeded with a changelog whose section is {defect}:\n{output}"); + StringAssert.Contains(output, string.Format(System.Globalization.CultureInfo.InvariantCulture, expected, version), $"Pack failed, but not for the {defect} section:\n{output}"); + StringAssert.Contains(output, changelog, $"The pack error does not name the changelog:\n{output}"); + } + finally + { + Directory.Delete(Path.GetDirectoryName(changelog)!, recursive: true); + } + } - Assert.AreEqual( - Path.Combine(project.Directory.FullName, "CHANGELOG.md"), file.FullName, - $"{project.PackageId}'s release notes link to {target}, which is not its own changelog. " - + "Keep each project in a directory of its own name under src/."); - Assert.IsTrue(file.Exists, $"{project.PackageId}'s release notes link to {target}, which does not exist."); + private static FileInfo ChangelogOf(PackableProject project) => new(Path.Combine(project.Directory.FullName, "CHANGELOG.md")); + + private static string RepoPath(FileInfo file) => + Path.GetRelativePath(RepoLayout.Root.FullName, file.FullName).Replace('\\', '/'); + + /// Matches an inline Markdown link to an absolute URL: [text](https://…). + private static readonly Regex MarkdownLink = new(@"\[(?[^\[\]]+)\]\((?https?://[^\s()]+)\)"); + + /// + /// The release notes a package must carry: the lines under its changelog's heading for the + /// shipped version, up to the next ##, without the blank lines around them, in plain text, + /// then a blank line and the link to the changelog at the version's tag. + /// + private static string ExpectedReleaseNotes(PackableProject project) + { + var version = RepoLayout.ShippedVersion; + var changelog = ChangelogOf(project); + var lines = File.ReadAllLines(changelog.FullName).Select(line => line.TrimEnd()).ToList(); + var start = lines.FindIndex(line => line == $"## [{version}]" || line.StartsWith($"## [{version}] ", StringComparison.Ordinal)); + + Assert.AreNotEqual(-1, start, $"{RepoPath(changelog)} has no '## [{version}]' section."); + + var section = lines.Skip(start + 1) + .TakeWhile(line => !line.StartsWith("## ", StringComparison.Ordinal)) + .SkipWhile(string.IsNullOrEmpty) + .Reverse() + .SkipWhile(string.IsNullOrEmpty) + .Reverse() + .Select(line => line.StartsWith("### ", StringComparison.Ordinal) ? line[4..] : line) + .Select(line => MarkdownLink.Replace(line, match => $"{match.Groups["text"].Value} ({match.Groups["url"].Value})")) + .Select(line => line.Replace("`", "", StringComparison.Ordinal).Replace("**", "", StringComparison.Ordinal)); + + return string.Join("\n", section) + $"\n\nFull changelog: {RepositoryBlob}v{version}/{RepoPath(changelog)}"; + } + + /// + /// Runs NuGet's pack up to the nuspec for one project, with no build output, into a temporary + /// directory, and returns the exit code, the output and the nuspec it wrote. + /// + /// + /// _GetRestoreProjectStyle is what Pack runs first to set the property + /// GenerateNuspec is conditioned on; called alone, GenerateNuspec skips without a + /// word. Restore must have run, as it has once the solution is built. + /// + private static (int Exit, string Output, XDocument? Nuspec) GenerateNuspec(PackableProject project, params string[] properties) + { + var directory = Path.Combine(Path.GetTempPath(), $"nuspec-{Guid.NewGuid():N}"); + + var info = new ProcessStartInfo(Environment.GetEnvironmentVariable("DOTNET_HOST_PATH") is { Length: > 0 } host ? host : "dotnet") + { + WorkingDirectory = RepoLayout.Root.FullName, + RedirectStandardOutput = true, + RedirectStandardError = true + }; + foreach (var argument in new[] + { + "msbuild", project.File.FullName, "-t:_GetRestoreProjectStyle;GenerateNuspec", "-nologo", "-nodeReuse:false", + "-p:NoBuild=true", "-p:IncludeBuildOutput=false", "-p:IncludeSymbols=false", + "-p:ContinuePackingAfterGeneratingNuspec=false", + $"-p:NuspecOutputPath={directory}/", $"-p:PackageOutputPath={directory}/" + }.Concat(properties)) + info.ArgumentList.Add(argument); + + try + { + using var msbuild = Process.Start(info) ?? throw new InvalidOperationException("dotnet could not be started."); + var error = msbuild.StandardError.ReadToEndAsync(); + var output = msbuild.StandardOutput.ReadToEnd(); + msbuild.WaitForExit(); + + var nuspec = Directory.Exists(directory) ? Directory.GetFiles(directory, "*.nuspec").SingleOrDefault() : null; + + return (msbuild.ExitCode, output + error.Result, nuspec is null ? null : XDocument.Load(nuspec)); + } + finally + { + if (Directory.Exists(directory)) Directory.Delete(directory, recursive: true); } } diff --git a/test/consumer-smoke-test.sh b/test/consumer-smoke-test.sh index 16a74c6..87982f7 100755 --- a/test/consumer-smoke-test.sh +++ b/test/consumer-smoke-test.sh @@ -96,6 +96,44 @@ assert_contains() { fi } +assert_absent() { + if grep -qF -- "$1" "$2"; then + echo " FAIL: $3 (found '$1' in $(basename "$2"))" + failed=1 + else + echo " ok: $3" + fi +} + +# The release notes in each package are its version's section of its own CHANGELOG.md, in plain +# text, ending with a link to that changelog at the version's tag (#346). PackagingConventionTests +# compares a freshly generated nuspec with the section line for line; this reads the .nupkg itself, +# which in the release workflow's mode is the package about to be pushed. The first line of the +# section is checked in the plain form nuget.org shows: no heading marker, a link as `text (url)`, +# no backticks or bold, and XML-escaped as the nuspec holds it. +echo "==> Release notes in each package" +for id in CodoMetis.ValueRanges CodoMetis.ValueRanges.NodaTime CodoMetis.ValueRanges.EFCore.PostgreSQL CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTime; do + nuspec="$work/$id.nuspec" + notes="$work/$id.notes" + unzip -p "$feed/$id.$version.nupkg" "$id.nuspec" > "$nuspec" + sed -n '//,/<\/releaseNotes>/p' "$nuspec" > "$notes" + first="$(awk -v heading="## [$version]" ' + index($0, heading) == 1 { inside = 1; next } + inside && /^## / { exit } + inside && NF { print; exit }' "$repo_root/src/$id/CHANGELOG.md" \ + | sed -E -e 's/^### //' -e 's/\[([^][]+)\]\((https?:\/\/[^()[:space:]]+)\)/\1 (\2)/g' -e 's/`//g' -e 's/\*\*//g' \ + -e 's/&/\&/g' -e 's//\>/g')" + if [[ -z "$first" ]]; then + echo " FAIL: $id: src/$id/CHANGELOG.md has no '## [$version]' section with a line in it" + failed=1 + else + assert_contains "$first" "$notes" "$id: release notes open with its '## [$version]' section" + fi + assert_contains "Full changelog: https://github.com/CaffeinatedCoder/CodoMetis.ValueRanges/blob/v$version/src/$id/CHANGELOG.md" "$notes" "$id: release notes end with its changelog at v$version" + assert_absent '`' "$notes" "$id: release notes hold no Markdown backticks" + assert_absent '](' "$notes" "$id: release notes hold no Markdown links" +done + # Creates a consumer project outside the repository and installs one package of this repository, plus # any further packages from nuget.org given as id@version. Program.cs is written by the caller # beforehand, at $work/.cs. Restores, builds, runs, and leaves the program's