diff --git a/CHANGELOG.md b/CHANGELOG.md index a8c959b..396fc43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -87,7 +87,7 @@ See the Nerdbank.GitVersioning removal entry under _Changes to existing features - **BREAKING CHANGE**: `bv` no longer forces `-maxcpucount:1` on the `dotnet` invocations of `restore`/`build`/`test`/`pack`. MSBuild now uses its default parallelism unless you forward your own `-m`/`-maxcpucount` switch. - **BREAKING CHANGE**: The `-c`/`--configuration` option is no longer parsed by `bv restore`/`build`/`test`/`pack`; for those commands it is just another forwarded argument, passed after the `--` separator. `bv` emits the configured default build configuration (`dotnet.configuration` in `buildvana.json`, or `Release`) as an overridable default, so a forwarded `-c`/`-p:Configuration=` still wins (e.g. `bv build -- -c Debug` builds `Debug`). `bv release` keeps `-c`/`--configuration` as a parsed option, since it needs the value to locate build artifacts. - **BREAKING CHANGE**: The `--main-branch` global option has been removed, along with `bv`'s main-branch discovery. Documentation generation on release is now gated by `release.generateDocsFrom` in `buildvana.json`: a list of regular expressions matched against the current short branch name (default `["^main$", "^master$"]`, reproducing the previous main/master discovery). The human-curated changelog permalink in generated release notes now points at the release branch itself rather than the discovered main branch. -- **BREAKING CHANGE**: The `--unstable-changelog` and `--require-changelog` options of `bv release` have been removed with no CLI replacement; changelog policy is repository-stable, not per-invocation. Configure it in `buildvana.json` instead: `release.changelogUpdates` (`none` | `stable` | `all`, default `stable`) selects which releases update the changelog, and `release.emptyChangelog` provides substitute text for an empty "Unreleased changes" section (when unset, an empty section fails the release, matching the previous `--require-changelog` default of `true`). +- **BREAKING CHANGE**: The `--unstable-changelog` and `--require-changelog` options of `bv release` have been removed with no CLI replacement; changelog policy is repository-stable, not per-invocation. Configure it in `buildvana.json` instead: `release.changelogUpdates` (`none` | `stable` | `all`, default `stable`) selects which releases update the changelog, and `release.emptyChangelog` provides substitute text for an empty "Unreleased changes" section (when unset or blank, an empty section fails the release, matching the previous `--require-changelog` default of `true`). - `bv`'s build commands (`clean`, `restore`, `build`, `test`, `pack`) and `release` now observe cancellation. Pressing Ctrl-C (or a host cancelling the operation) stops the pipeline promptly: it stops launching further steps and terminates the running `dotnet` child process instead of waiting for it to finish, then `bv` exits with code 130. Partial build output may be left behind on cancellation; `bv clean` recovers. - **BREAKING CHANGE**: Nerdbank.GitVersioning has been removed from Buildvana SDK and `bv`. Versions are now computed natively from a `VERSION` file and `buildvana.json` keys (see _New features_ above): the `NerdbankGitVersioning` SDK module is gone, the `Nerdbank.GitVersioning` package is no longer injected into projects, `version.json` is no longer read, and `bv` no longer invokes the `nbgv` CLI. The `GetBuildVersion` target name is retained (as a real target or a stub) for targets that depend on it. To migrate a repository: - create a `VERSION` file at the repository root holding the `version` value from `version.json` (e.g. `2.0-preview`). **The Git height restarts at the commit that creates `VERSION`**: it counts commits since `MAJOR.MINOR` last changed in that file, and a file that did not exist before counts as a change, so the history of the `version.json` it replaces does not carry over. If your latest published patch number is high, bump `MAJOR.MINOR` in the same commit: on a fresh version line the restart is harmless, whereas keeping the old line computes versions lower than the ones you already published; @@ -103,7 +103,8 @@ See the Nerdbank.GitVersioning removal entry under _Changes to existing features - `bv release` no longer tags and publishes a version one patch above the one its artifacts were built with. The "Prepare release" commit bumps the Git height, hence the version, but it was only created when an earlier step had a file to commit; a release with nothing to commit before the build (typically a prerelease with no version-spec change and `release.changelogUpdates` set to `stable` or `none`) therefore built and pushed its packages at the pre-commit version, then created the commit, and tagged and released the version above. The release commit is now always created before the build. - URLs that `bv release` builds from the repository URL are no longer missing the separator before their first path segment: release links (`.../Buildvanareleases/tag/1.1.10`) and file links (`.../Buildvanablob/main/CHANGELOG.md`) now come out as `.../Buildvana/releases/tag/1.1.10` and `.../Buildvana/blob/main/CHANGELOG.md`. This affected the version section titles written into the changelog and the "human-curated changelog" link at the top of every generated release description; the titles already written for 1.0.220, 1.1.4, and 1.1.10 have been corrected in place. - `bv clean` no longer silently ignores unknown options: `bv clean --bogus` now fails with `Unknown option '--bogus' for command 'clean'`. Every `bv` command now rejects options it does not recognize, and does so before anything else runs: previously, commands that parse their own options (e.g. `bv release`) reported an unknown option only after the SDK version check, so a mismatched SDK pin could mask the typo. -- A denied or failed file or directory access during a `bv` command (e.g. a locked or read-only `CHANGELOG.md`, `Directory.Packages.props`, or public API file, or a `bin` directory locked by Visual Studio during `bv clean`) no longer surfaces as an unhandled-exception stack trace pointing at `bv` internals. File and directory accesses now report failure as a single clean error line naming the operation, the path, and the operating-system reason (`Could not read from : `), and `bv` exits with its regular failure exit code. +- A denied or failed file or directory access during a `bv` command (e.g. a locked or read-only `CHANGELOG.md`, `Directory.Packages.props`, or public API file, or a `bin` directory locked by Visual Studio during `bv clean`) no longer surfaces as an unhandled-exception stack trace pointing at `bv` internals. File and directory accesses now report failure as a single clean error line naming the operation, the path, and the operating-system reason (`Could not read from : `), and `bv` exits with its regular failure exit code. This covers failures that happen part-way through reading a file, not just failures to open it: `bv release` reads the whole of `CHANGELOG.md` before rewriting it, so a file yanked mid-read (say, by a cloud-sync provider) is reported the same clean way. +- The release date in the changelog section titles written by `bv release` is now always formatted with the Gregorian calendar and the invariant date format. On a machine whose culture prescribes a different calendar (e.g. `th-TH`, `ar-SA`), the date written into `CHANGELOG.md` was the current culture's rendering of the day (`2569-04-27` rather than `2026-04-27`), and neither matched the release tag nor sorted with the other section titles. ### Known problems introduced by this release diff --git a/schemas/buildvana.schema.json b/schemas/buildvana.schema.json index 75a7215..d0da081 100644 --- a/schemas/buildvana.schema.json +++ b/schemas/buildvana.schema.json @@ -42,7 +42,7 @@ ] }, "emptyChangelog": { - "description": "Text substituted when a release has no changelog entries. When omitted, an empty changelog fails the release.", + "description": "Text substituted when a release has no changelog entries. When omitted or blank, an empty changelog fails the release.", "type": "string" }, "dogfood": { diff --git a/src/Buildvana.Runtime/ReleaseConfig.cs b/src/Buildvana.Runtime/ReleaseConfig.cs index 83624a7..1c35d0a 100644 --- a/src/Buildvana.Runtime/ReleaseConfig.cs +++ b/src/Buildvana.Runtime/ReleaseConfig.cs @@ -14,11 +14,15 @@ namespace Buildvana.Runtime; public sealed record ReleaseConfig { /// Gets the regular expressions identifying branches that produce public releases. - [Description("Regular expressions (implicitly anchored, matched against the whole short branch name) identifying branches that produce public releases.")] + [Description( + "Regular expressions (implicitly anchored, matched against the whole short branch name) " + + "identifying branches that produce public releases.")] public IReadOnlyList? Branches { get; init; } /// Gets the regular expressions identifying branches that documentation is generated from. - [Description("Regular expressions (implicitly anchored, matched against the whole short branch name) identifying branches that documentation is generated from.")] + [Description( + "Regular expressions (implicitly anchored, matched against the whole short branch name) " + + "identifying branches that documentation is generated from.")] public IReadOnlyList? GenerateDocsFrom { get; init; } /// Gets the build configuration used to produce release artifacts. @@ -34,7 +38,7 @@ public sealed record ReleaseConfig public ChangelogUpdates? ChangelogUpdates { get; init; } /// Gets the text substituted when a release has no changelog entries. - [Description("Text substituted when a release has no changelog entries. When omitted, an empty changelog fails the release.")] + [Description("Text substituted when a release has no changelog entries. When omitted or blank, an empty changelog fails the release.")] public string? EmptyChangelog { get; init; } /// Gets a value indicating whether self-references are updated (dogfooding) during a release. diff --git a/src/Buildvana.Sdk.Tasks/Tasks/ComputeVersion.cs b/src/Buildvana.Sdk.Tasks/Tasks/ComputeVersion.cs index 9e3b458..4c32020 100644 --- a/src/Buildvana.Sdk.Tasks/Tasks/ComputeVersion.cs +++ b/src/Buildvana.Sdk.Tasks/Tasks/ComputeVersion.cs @@ -48,8 +48,8 @@ public sealed partial class ComputeVersion : BuildvanaSdkTask protected override Undefined Run() { - BuildFailedException.ThrowIfNot( - !string.IsNullOrEmpty(HomeDirectory), + BuildFailedException.ThrowIf( + string.IsNullOrEmpty(HomeDirectory), string.Format(CultureInfo.InvariantCulture, Strings.MissingParameterFmt, nameof(HomeDirectory))); var version = GetOrComputeVersion(HomeDirectory, Reporter); diff --git a/src/Buildvana.Sdk.Tasks/Tasks/ConvertPfxToSnk.cs b/src/Buildvana.Sdk.Tasks/Tasks/ConvertPfxToSnk.cs index c468d69..e931479 100644 --- a/src/Buildvana.Sdk.Tasks/Tasks/ConvertPfxToSnk.cs +++ b/src/Buildvana.Sdk.Tasks/Tasks/ConvertPfxToSnk.cs @@ -26,11 +26,11 @@ public sealed class ConvertPfxToSnk : BuildvanaSdkTask protected override Undefined Run() { - BuildFailedException.ThrowIfNot( - !string.IsNullOrEmpty(PfxPath), + BuildFailedException.ThrowIf( + string.IsNullOrEmpty(PfxPath), string.Format(CultureInfo.InvariantCulture, Strings.MissingParameterFmt, nameof(PfxPath))); - BuildFailedException.ThrowIfNot( - !string.IsNullOrEmpty(OutputPath), + BuildFailedException.ThrowIf( + string.IsNullOrEmpty(OutputPath), string.Format(CultureInfo.InvariantCulture, Strings.MissingParameterFmt, nameof(OutputPath))); using var cert = LoadCertificate(PfxPath, PfxPassword); diff --git a/src/Buildvana.Sdk.Tasks/Tasks/GetWinePath.cs b/src/Buildvana.Sdk.Tasks/Tasks/GetWinePath.cs index e603f28..7584b6d 100644 --- a/src/Buildvana.Sdk.Tasks/Tasks/GetWinePath.cs +++ b/src/Buildvana.Sdk.Tasks/Tasks/GetWinePath.cs @@ -23,8 +23,8 @@ public sealed class GetWinePath : BuildvanaSdkTask protected override Undefined Run() { - BuildFailedException.ThrowIfNot( - !string.IsNullOrEmpty(HostPath), + BuildFailedException.ThrowIf( + string.IsNullOrEmpty(HostPath), string.Format(CultureInfo.InvariantCulture, Strings.MissingParameterFmt, nameof(HostPath))); WinePath = WinePathUtility.ConvertToWinePath(HostPath, BasePath); diff --git a/src/Buildvana.Sdk.Tasks/Tasks/WriteThisAssemblyConstantsFile.cs b/src/Buildvana.Sdk.Tasks/Tasks/WriteThisAssemblyConstantsFile.cs index 6a723d6..131b722 100644 --- a/src/Buildvana.Sdk.Tasks/Tasks/WriteThisAssemblyConstantsFile.cs +++ b/src/Buildvana.Sdk.Tasks/Tasks/WriteThisAssemblyConstantsFile.cs @@ -30,8 +30,8 @@ public sealed class WriteThisAssemblyConstantsFile : BuildvanaSdkTask protected override Undefined Run() { - BuildFailedException.ThrowIfNot( - !string.IsNullOrEmpty(OutputPath), + BuildFailedException.ThrowIf( + string.IsNullOrEmpty(OutputPath), string.Format(CultureInfo.InvariantCulture, Strings.MissingParameterFmt, nameof(OutputPath))); var sb = new StringBuilder(); diff --git a/src/Buildvana.Tool/Services/ChangelogService.cs b/src/Buildvana.Tool/Services/ChangelogService.cs index 3023a76..3692fcd 100644 --- a/src/Buildvana.Tool/Services/ChangelogService.cs +++ b/src/Buildvana.Tool/Services/ChangelogService.cs @@ -2,13 +2,8 @@ // See the LICENSE file in the project root for full license information. using System; -using System.Collections.Generic; -using System.Globalization; using System.IO; -using System.Linq; using System.Text; -using System.Text.RegularExpressions; -using Buildvana.Core; using Buildvana.Core.ConsoleOutput; using Buildvana.Core.IO; using Buildvana.Tool.Services.ServerAdapters; @@ -18,15 +13,29 @@ namespace Buildvana.Tool.Services; /// -/// Manages the repository's changelog in Markdown format, according to the Keep a Changelog specification. +/// Manages the repository's changelog in Markdown format, according to the +/// Keep a Changelog specification. /// -internal sealed partial class ChangelogService +/// +/// The changelog is always read in a single guarded call, so that an I/O failure at any point of the read — +/// not just when opening the file — is reported as a clean error instead of an unhandled exception. Parsing and +/// rewriting are left to . +/// +internal sealed class ChangelogService { /// /// The name of the changelog file. /// public const string FileName = "CHANGELOG.md"; + // The changelog is written without a BOM, and invalid UTF-8 is an error rather than something + // to paper over with replacement characters: a changelog that cannot be read faithfully cannot + // be rewritten faithfully either. The strict decoding is best-effort on reads, though: the file + // APIs detect byte order marks, and a detected BOM replaces this encoding with a stock one whose + // fallback substitutes U+FFFD. A changelog that carries a BOM and invalid bytes is therefore read + // and rewritten with replacement characters, as it was before this encoding was introduced. + private static readonly Encoding FileEncoding = new UTF8Encoding(false, true); + private readonly IReporter _reporter; private readonly ServerAdapter _server; private readonly VersionService _version; @@ -62,31 +71,8 @@ public bool HasUnreleasedChanges() return false; } - using var reader = UserFile.OpenText(FileName, Encoding.UTF8); - var sectionHeadingRegex = GetSectionHeadingRegex(); - var subSectionHeadingRegex = GetSubsectionHeadingRegex(); - string? line; - do - { - line = reader.ReadLine(); - } while (line != null && !sectionHeadingRegex.IsMatch(line)); - - BuildFailedException.ThrowIfNot(line != null, $"{FileName} contains no sections."); - for (; ;) - { - line = reader.ReadLine(); - if (line == null || sectionHeadingRegex.IsMatch(line)) - { - break; - } - - if (!string.IsNullOrWhiteSpace(line) && !subSectionHeadingRegex.IsMatch(line)) - { - return true; - } - } - - return false; + var lines = UserFile.ReadAllLines(FileName, FileEncoding); + return ChangelogUpdater.HasUnreleasedChanges(lines); } /// @@ -94,139 +80,14 @@ public bool HasUnreleasedChanges() /// to a new section. /// /// Text to use as the new section's body when the "Unreleased changes" - /// section has no content. When , an empty section is moved verbatim (producing a - /// title-only section). + /// section has no content. When , or all whitespace, an empty section is moved + /// verbatim (producing a title-only section). public void PrepareForRelease(string? emptyChangelogSubstitute = null) { _reporter.Info("Updating changelog..."); - var encoding = new UTF8Encoding(false, true); - var sb = new StringBuilder(); - using (var reader = UserFile.OpenText(FileName, encoding)) - using (var writer = new StringWriter(sb, CultureInfo.InvariantCulture)) - { - // Using a StringWriter instead of a StringBuilder allows for a custom line separator - // Under Windows, a StringBuilder would only use "\r\n" as a line separator, which would be wrong in this case - writer.NewLine = "\n"; - var sectionHeadingRegex = GetSectionHeadingRegex(); - var subSectionHeadingRegex = GetSubsectionHeadingRegex(); - var subSections = new List<(string Header, List Lines)> { (string.Empty, []) }; - var subSectionIndex = 0; - - const int readingFileHeader = 0; - const int readingUnreleasedChangesSection = 1; - const int readingRemainderOfFile = 2; - const int readingDone = 3; - var state = readingFileHeader; - while (state != readingDone) - { - var line = reader.ReadLine(); - switch (state) - { - case readingFileHeader: - BuildFailedException.ThrowIfNot(line != null, $"{FileName} contains no sections."); - - // Copy everything up to an including the first section heading (which we assume is "Unreleased changes") - writer.WriteLine(line); - if (sectionHeadingRegex.IsMatch(line)) - { - state = readingUnreleasedChangesSection; - } - - break; - case readingUnreleasedChangesSection: - if (line == null) - { - // The changelog only contains the "Unreleased changes" section; - // this happens when no release has been published yet - WriteNewSections(true); - state = readingDone; - break; - } - - if (sectionHeadingRegex.IsMatch(line)) - { - // Reached header of next section - WriteNewSections(false); - writer.WriteLine(line); - state = readingRemainderOfFile; - break; - } - - if (subSectionHeadingRegex.IsMatch(line)) - { - subSections.Add((line, [])); - ++subSectionIndex; - break; - } - - subSections[subSectionIndex].Lines.Add(line); - break; - case readingRemainderOfFile: - if (line == null) - { - state = readingDone; - break; - } - - writer.WriteLine(line); - break; - } - } - - void WriteNewSections(bool atEndOfFile) - { - // Create empty subsections in new "Unreleased changes" section - foreach (var (header, _) in subSections.Skip(1)) - { - writer.WriteLine(string.Empty); - writer.WriteLine(header); - } - - // Write header of new release section - writer.WriteLine(string.Empty); - writer.WriteLine("## " + MakeSectionTitle()); - - var newSectionLines = CollectNewSectionLines(); - var newSectionCount = newSectionLines.Count; - if (atEndOfFile) - { - // If there is no other section after the new release, - // we don't want extra blank lines at EOF - while (newSectionCount > 0 && string.IsNullOrEmpty(newSectionLines[newSectionCount - 1])) - { - --newSectionCount; - } - } - - foreach (var newSectionLine in newSectionLines.Take(newSectionCount)) - { - writer.WriteLine(newSectionLine); - } - } - - List CollectNewSectionLines() - { - var result = new List(subSections[0].Lines); - - // Copy only subsections that have actual content - foreach (var (header, lines) in subSections.Skip(1).Where(s => s.Lines.Any(l => !string.IsNullOrWhiteSpace(l)))) - { - result.Add(header); - result.AddRange(lines); - } - - // When the "Unreleased changes" section has no real content, substitute the configured text (if any). - if (emptyChangelogSubstitute is not null && result.All(string.IsNullOrWhiteSpace)) - { - result.Clear(); - result.AddRange(emptyChangelogSubstitute.ReplaceLineEndings("\n").Split('\n')); - } - - return result; - } - } - - UserFile.WriteAllText(FileName, sb.ToString(), encoding); + var lines = UserFile.ReadAllLines(FileName, FileEncoding); + var text = ChangelogUpdater.PrepareForRelease(lines, MakeSectionTitle, emptyChangelogSubstitute); + UserFile.WriteAllText(FileName, text, FileEncoding); } /// @@ -236,69 +97,11 @@ List CollectNewSectionLines() public void UpdateNewSectionTitle() { _reporter.Info("Updating changelog's new release section title..."); - var encoding = new UTF8Encoding(false, true); - var sb = new StringBuilder(); - using (var reader = UserFile.OpenText(FileName, encoding)) - using (var writer = new StringWriter(sb, CultureInfo.InvariantCulture)) - { - // Using a StringWriter instead of a StringBuilder allows for a custom line separator - // Under Windows, a StringBuilder would only use "\r\n" as a line separator, which would be wrong in this case - writer.NewLine = "\n"; - var sectionHeadingRegex = GetSectionHeadingRegex(); - - const int readingFileHeader = 0; - const int readingUnreleasedChangesSection = 1; - const int readingRemainderOfFile = 2; - const int readingDone = 3; - var state = readingFileHeader; - while (state != readingDone) - { - var line = reader.ReadLine(); - switch (state) - { - case readingFileHeader: - BuildFailedException.ThrowIfNot(line != null, $"{FileName} contains no sections."); - writer.WriteLine(line); - if (sectionHeadingRegex.IsMatch(line)) - { - state = readingUnreleasedChangesSection; - } - - break; - case readingUnreleasedChangesSection: - BuildFailedException.ThrowIfNot(line != null, $"{FileName} contains only one section."); - if (sectionHeadingRegex.IsMatch(line)) - { - // Replace header of second section - writer.WriteLine("## " + MakeSectionTitle()); - state = readingRemainderOfFile; - break; - } - - writer.WriteLine(line); - break; - case readingRemainderOfFile: - if (line == null) - { - state = readingDone; - break; - } - - writer.WriteLine(line); - break; - } - } - } - - UserFile.WriteAllText(FileName, sb.ToString(), encoding); + var lines = UserFile.ReadAllLines(FileName, FileEncoding); + var text = ChangelogUpdater.UpdateNewSectionTitle(lines, MakeSectionTitle); + UserFile.WriteAllText(FileName, text, FileEncoding); } - [GeneratedRegex("^ {0,3}##($|[^#])", RegexOptions.Compiled | RegexOptions.CultureInvariant)] - private static partial Regex GetSectionHeadingRegex(); - - [GeneratedRegex("^ {0,3}###($|[^#])", RegexOptions.Compiled | RegexOptions.CultureInvariant)] - private static partial Regex GetSubsectionHeadingRegex(); - private string MakeSectionTitle() - => $"[{_version.CurrentStr}]({_server.GetReleaseUrl(_version.CurrentStr)}) ({DateTime.Now:yyyy-MM-dd})"; + => ChangelogUpdater.MakeSectionTitle(_version.CurrentStr, _server.GetReleaseUrl(_version.CurrentStr), DateTime.Now); } diff --git a/src/Buildvana.Tool/Services/ChangelogUpdater.cs b/src/Buildvana.Tool/Services/ChangelogUpdater.cs new file mode 100644 index 0000000..a4f7ac2 --- /dev/null +++ b/src/Buildvana.Tool/Services/ChangelogUpdater.cs @@ -0,0 +1,315 @@ +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using Buildvana.Core; +using CommunityToolkit.Diagnostics; + +namespace Buildvana.Tool.Services; + +/// +/// Inspects and rewrites the contents of a changelog in Markdown format, according to the +/// Keep a Changelog specification. +/// +/// +/// The methods of this class work on changelog contents already read, leaving file access +/// to . +/// +internal static partial class ChangelogUpdater +{ + /// + /// Checks a changelog for contents in the "Unreleased changes" section. + /// + /// The lines of the changelog. + /// If there are any contents (excluding blank lines and subsection headings) + /// in the "Unreleased changes" section, ; otherwise, . + /// The changelog contains no sections. + public static bool HasUnreleasedChanges(IReadOnlyList lines) + { + Guard.IsNotNull(lines); + var sectionHeadingRegex = GetSectionHeadingRegex(); + var subSectionHeadingRegex = GetSubsectionHeadingRegex(); + var lineIndex = 0; + string? line; + do + { + line = GetLineOrNull(lines, lineIndex++); + } while (line is not null && !sectionHeadingRegex.IsMatch(line)); + + BuildFailedException.ThrowIf(line is null, $"{ChangelogService.FileName} contains no sections."); + for (; ;) + { + line = GetLineOrNull(lines, lineIndex++); + if (line is null || sectionHeadingRegex.IsMatch(line)) + { + break; + } + + if (!string.IsNullOrWhiteSpace(line) && !subSectionHeadingRegex.IsMatch(line)) + { + return true; + } + } + + return false; + } + + /// + /// Rewrites a changelog for a new release, moving the contents of the "Unreleased changes" section + /// to a new section. + /// + /// The lines of the changelog. + /// A callback that produces the title of the new section. It is called + /// at most once, and only if a new section is actually written. + /// Text to use as the new section's body when the "Unreleased changes" + /// section has no content. When , or all whitespace, an empty section is moved + /// verbatim (producing a title-only section). + /// The rewritten changelog, with lines separated by "\n". + /// The changelog contains no sections. + public static string PrepareForRelease( + IReadOnlyList lines, + Func makeSectionTitle, + string? emptyChangelogSubstitute) + { + Guard.IsNotNull(lines); + Guard.IsNotNull(makeSectionTitle); + + // Using a StringWriter instead of a StringBuilder allows for a custom line separator + // Under Windows, a StringBuilder would only use "\r\n" as a line separator, which would be wrong in this case + var sb = new StringBuilder(); + using var writer = new StringWriter(sb, CultureInfo.InvariantCulture); + writer.NewLine = "\n"; + var sectionHeadingRegex = GetSectionHeadingRegex(); + var subSectionHeadingRegex = GetSubsectionHeadingRegex(); + var subSections = new List<(string Header, List Lines)> { (string.Empty, []) }; + var subSectionIndex = 0; + + const int readingFileHeader = 0; + const int readingUnreleasedChangesSection = 1; + const int readingRemainderOfFile = 2; + const int readingDone = 3; + var lineIndex = 0; + var state = readingFileHeader; + while (state != readingDone) + { + var line = GetLineOrNull(lines, lineIndex++); + switch (state) + { + case readingFileHeader: + BuildFailedException.ThrowIf(line is null, $"{ChangelogService.FileName} contains no sections."); + + // Copy everything up to and including the first section heading (which we assume is "Unreleased changes") + writer.WriteLine(line); + if (sectionHeadingRegex.IsMatch(line)) + { + state = readingUnreleasedChangesSection; + } + + break; + case readingUnreleasedChangesSection: + if (line is null) + { + // The changelog only contains the "Unreleased changes" section; + // this happens when no release has been published yet + WriteNewSections(true); + state = readingDone; + break; + } + + if (sectionHeadingRegex.IsMatch(line)) + { + // Reached header of next section + WriteNewSections(false); + writer.WriteLine(line); + state = readingRemainderOfFile; + break; + } + + if (subSectionHeadingRegex.IsMatch(line)) + { + subSections.Add((line, [])); + ++subSectionIndex; + break; + } + + subSections[subSectionIndex].Lines.Add(line); + break; + case readingRemainderOfFile: + if (line is null) + { + state = readingDone; + break; + } + + writer.WriteLine(line); + break; + } + } + + return sb.ToString(); + + void WriteNewSections(bool atEndOfFile) + { + // Create empty subsections in new "Unreleased changes" section + foreach (var (header, _) in subSections.Skip(1)) + { + writer.WriteLine(string.Empty); + writer.WriteLine(header); + } + + // Write header of new release section + writer.WriteLine(string.Empty); + writer.WriteLine("## " + makeSectionTitle()); + + var newSectionLines = CollectNewSectionLines(); + var newSectionCount = newSectionLines.Count; + if (atEndOfFile) + { + // If there is no other section after the new release, + // we don't want extra blank lines at EOF + while (newSectionCount > 0 && string.IsNullOrEmpty(newSectionLines[newSectionCount - 1])) + { + --newSectionCount; + } + } + + foreach (var newSectionLine in newSectionLines.Take(newSectionCount)) + { + writer.WriteLine(newSectionLine); + } + } + + List CollectNewSectionLines() + { + var result = new List(subSections[0].Lines); + + // Copy only subsections that have actual content + foreach (var (header, subSectionLines) in subSections.Skip(1).Where(s => s.Lines.Any(l => !string.IsNullOrWhiteSpace(l)))) + { + result.Add(header); + result.AddRange(subSectionLines); + } + + // When the "Unreleased changes" section has no real content, substitute the configured text (if any). + // The substitute replaces the whole body of the section, blank lines included, so it is trimmed + // and surrounded with blank lines of our own: whatever padding it was configured with, the new + // section reads like every other one. A substitute that is all whitespace has nothing to + // contribute once trimmed, and is treated as no substitute at all. + var substitute = emptyChangelogSubstitute is not null && result.All(string.IsNullOrWhiteSpace) + ? emptyChangelogSubstitute.ReplaceLineEndings("\n").Trim() + : null; + if (!string.IsNullOrEmpty(substitute)) + { + result.Clear(); + result.Add(string.Empty); + result.AddRange(substitute.Split('\n')); + result.Add(string.Empty); + } + + return result; + } + } + + /// + /// Rewrites a changelog, replacing the heading of the first section after the "Unreleased changes" section + /// to reflect a change in the released version. + /// + /// The lines of the changelog. + /// A callback that produces the new title of the section. It is called + /// at most once, and only if a section heading is actually replaced. + /// The rewritten changelog, with lines separated by "\n". + /// The changelog contains no sections, or only one section. + public static string UpdateNewSectionTitle(IReadOnlyList lines, Func makeSectionTitle) + { + Guard.IsNotNull(lines); + Guard.IsNotNull(makeSectionTitle); + + // Using a StringWriter instead of a StringBuilder allows for a custom line separator + // Under Windows, a StringBuilder would only use "\r\n" as a line separator, which would be wrong in this case + var sb = new StringBuilder(); + using var writer = new StringWriter(sb, CultureInfo.InvariantCulture); + writer.NewLine = "\n"; + var sectionHeadingRegex = GetSectionHeadingRegex(); + + const int readingFileHeader = 0; + const int readingUnreleasedChangesSection = 1; + const int readingRemainderOfFile = 2; + const int readingDone = 3; + var lineIndex = 0; + var state = readingFileHeader; + while (state != readingDone) + { + var line = GetLineOrNull(lines, lineIndex++); + switch (state) + { + case readingFileHeader: + BuildFailedException.ThrowIf(line is null, $"{ChangelogService.FileName} contains no sections."); + writer.WriteLine(line); + if (sectionHeadingRegex.IsMatch(line)) + { + state = readingUnreleasedChangesSection; + } + + break; + case readingUnreleasedChangesSection: + BuildFailedException.ThrowIf(line is null, $"{ChangelogService.FileName} contains only one section."); + if (sectionHeadingRegex.IsMatch(line)) + { + // Replace header of second section + writer.WriteLine("## " + makeSectionTitle()); + state = readingRemainderOfFile; + break; + } + + writer.WriteLine(line); + break; + case readingRemainderOfFile: + if (line is null) + { + state = readingDone; + break; + } + + writer.WriteLine(line); + break; + } + } + + return sb.ToString(); + } + + /// + /// Composes the title of the changelog section for a release. + /// + /// The version being released. + /// The URL of the release. + /// The date of the release. + /// The section title, without the leading "## ". + /// + /// The date ends up in the changelog's contents, so it is formatted with the invariant culture: it must + /// come out the same on every machine, whatever calendar and date format the current culture prescribes. + /// + public static string MakeSectionTitle(string version, Uri releaseUrl, DateTime date) + { + Guard.IsNotNull(version); + Guard.IsNotNull(releaseUrl); + return string.Create(CultureInfo.InvariantCulture, $"[{version}]({releaseUrl}) ({date:yyyy-MM-dd})"); + } + + // Reading past the last line yields null, just as TextReader.ReadLine does at end of file: + // the state machines above pivot on it to recognize the end of the changelog. + private static string? GetLineOrNull(IReadOnlyList lines, int index) + => index < lines.Count ? lines[index] : null; + + [GeneratedRegex("^ {0,3}##($|[^#])", RegexOptions.Compiled | RegexOptions.CultureInvariant)] + private static partial Regex GetSectionHeadingRegex(); + + [GeneratedRegex("^ {0,3}###($|[^#])", RegexOptions.Compiled | RegexOptions.CultureInvariant)] + private static partial Regex GetSubsectionHeadingRegex(); +} diff --git a/src/Buildvana.Tool/Services/Git/GitService.cs b/src/Buildvana.Tool/Services/Git/GitService.cs index 065540f..d976a45 100644 --- a/src/Buildvana.Tool/Services/Git/GitService.cs +++ b/src/Buildvana.Tool/Services/Git/GitService.cs @@ -227,7 +227,7 @@ public void Stage(params string[] paths) public void Commit(string message, bool amend = false, bool allowEmpty = false) { var signature = _repository.Config.BuildSignature(DateTimeOffset.Now); - BuildFailedException.ThrowIfNot(signature is not null, "Git: committer identity not set."); + BuildFailedException.ThrowIf(signature is null, "Git: committer identity not set."); var options = new CommitOptions() { AmendPreviousCommit = amend, AllowEmptyCommit = allowEmpty }; _ = _repository.Commit(message, signature, signature, options); } @@ -244,7 +244,7 @@ public void UndoLastCommit() { _reporter.Info("Undoing last commit..."); var previousCommit = _repository.Head.Tip.Parents.FirstOrDefault(); - BuildFailedException.ThrowIfNot(previousCommit is not null, "Git: cannot reset, there is no commit to go back to."); + BuildFailedException.ThrowIf(previousCommit is null, "Git: cannot reset, there is no commit to go back to."); _repository.Reset(ResetMode.Hard, previousCommit); } @@ -255,7 +255,7 @@ public void Push(bool force = false) { var head = _repository.Head; var remote = head.RemoteName; - BuildFailedException.ThrowIfNot(!string.IsNullOrEmpty(remote), "Git: cannot push, HEAD is not tracking any remote."); + BuildFailedException.ThrowIf(string.IsNullOrEmpty(remote), "Git: cannot push, HEAD is not tracking any remote."); var pushOptions = new PushOptions(); var pushCredentialsFallback = PushCredentialsFallback; if (pushCredentialsFallback is not null) diff --git a/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubServerAdapter.cs b/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubServerAdapter.cs index 1b7c321..2c07e0a 100644 --- a/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubServerAdapter.cs +++ b/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubServerAdapter.cs @@ -99,7 +99,7 @@ private GitHubServerAdapter(IServiceProvider services) public static void SetActionsStepOutput(string name, string value) { var outputFile = Environment.GetEnvironmentVariable("GITHUB_OUTPUT"); - BuildFailedException.ThrowIfNot(!string.IsNullOrEmpty(outputFile), "Cannot set Actions step output: GITHUB_OUTPUT not set."); + BuildFailedException.ThrowIf(string.IsNullOrEmpty(outputFile), "Cannot set Actions step output: GITHUB_OUTPUT not set."); UserFile.AppendAllLines(outputFile, [$"{name}={value}"], Encoding.UTF8); } diff --git a/src/Buildvana.Tool/Subcommands/ReleaseCommand.cs b/src/Buildvana.Tool/Subcommands/ReleaseCommand.cs index b5c1432..41f710f 100644 --- a/src/Buildvana.Tool/Subcommands/ReleaseCommand.cs +++ b/src/Buildvana.Tool/Subcommands/ReleaseCommand.cs @@ -59,7 +59,7 @@ public async Task ExecuteAsync(CancellationToken cancellationToken) // Perform some preliminary checks BuildFailedException.ThrowIfNot(server.IsCloudBuild, "A release can only be created on a known cloud build platform."); - BuildFailedException.ThrowIfNot(!string.IsNullOrEmpty(git.CurrentBranch), "A release can only be created from a branch."); + BuildFailedException.ThrowIf(string.IsNullOrEmpty(git.CurrentBranch), "A release can only be created from a branch."); BuildFailedException.ThrowIfNot(version.IsPublicRelease, "Cannot create a release from the current branch."); // Ensure that the CI bot identity is used for commits, if not already set. @@ -165,8 +165,8 @@ public async Task ExecuteAsync(CancellationToken cancellationToken) else { emptyChangelogSubstitute = settings.ResolveEmptyChangelog(); - BuildFailedException.ThrowIfNot( - emptyChangelogSubstitute is not null, + BuildFailedException.ThrowIf( + emptyChangelogSubstitute is null, "Changelog check failed: the \"Unreleased changes\" section is empty or only contains sub-section headings, and no substitute text is configured (release.emptyChangelog)."); reporter.Info("Changelog \"Unreleased changes\" section is empty; substituting the configured release.emptyChangelog text."); @@ -193,7 +193,7 @@ public async Task ExecuteAsync(CancellationToken cancellationToken) // Ensure that the release tag doesn't already exist. // This assumes that full repo history has been checked out; // however, that is already a prerequisite for computing the Git height. - BuildFailedException.ThrowIfNot(!git.TagExists(version.CurrentStr), $"Tag '{version.CurrentStr}' already exists in repository."); + BuildFailedException.ThrowIf(git.TagExists(version.CurrentStr), $"Tag '{version.CurrentStr}' already exists in repository."); // Artifact pass (Restore→Pack, no Clean): rebuild against the resolved version and make artifacts. await pipeline.RunRangeAsync(BuildStep.Restore, BuildStep.Pack, configuration, cancellationToken).ConfigureAwait(false); diff --git a/src/Buildvana.Tool/Subcommands/ReleaseSettings.cs b/src/Buildvana.Tool/Subcommands/ReleaseSettings.cs index 5b6f091..cd28b2c 100644 --- a/src/Buildvana.Tool/Subcommands/ReleaseSettings.cs +++ b/src/Buildvana.Tool/Subcommands/ReleaseSettings.cs @@ -79,7 +79,10 @@ private ReleaseSettings(ReleaseConfig? config, DotNetSettings dotNetSettings) /// The resolved dotnet settings, providing the fallback build configuration. /// The parsed settings. /// An option value is invalid. - public static ReleaseSettings Parse(IReadOnlyList options, BuildvanaConfig config, DotNetSettings dotNetSettings) + public static ReleaseSettings Parse( + IReadOnlyList options, + BuildvanaConfig config, + DotNetSettings dotNetSettings) { Guard.IsNotNull(options); Guard.IsNotNull(config); @@ -130,9 +133,18 @@ public VersionSpecChange ResolveBump() /// /// Returns the text substituted for an empty changelog (release.emptyChangelog), or - /// when unset (in which case an empty changelog fails the release). + /// when unset or all whitespace (in which case an empty changelog fails the release). /// - public string? ResolveEmptyChangelog() => _config?.EmptyChangelog; + /// + /// Text that is all whitespace would substitute nothing for nothing, so it counts as no substitute at all: + /// the release fails with the same actionable message as when release.emptyChangelog is missing, instead + /// of silently producing a release section with no body. + /// + public string? ResolveEmptyChangelog() + { + var configured = _config?.EmptyChangelog; + return string.IsNullOrWhiteSpace(configured) ? null : configured; + } /// /// Returns if set, otherwise release.dogfood, otherwise . @@ -144,7 +156,8 @@ public VersionSpecChange ResolveBump() /// against the configured release.generateDocsFrom regular expressions (default main/master). /// /// The short name of the branch the release is created from. - /// if matches at least one pattern; otherwise, . + /// if matches at least one pattern; + /// otherwise, . /// A configured pattern is not a valid regular expression, or matching timed out. /// /// Patterns are implicitly anchored (wrapped in ^(?:)$): a pattern must match the whole @@ -182,7 +195,8 @@ private static bool IsMatch(string pattern, string branch) } catch (RegexMatchTimeoutException) { - throw new BuildFailedException($"Regular expression '{pattern}' in release.generateDocsFrom timed out matching branch '{branch}'."); + throw new BuildFailedException( + $"Regular expression '{pattern}' in release.generateDocsFrom timed out matching branch '{branch}'."); } } diff --git a/tests/Buildvana.Tool.Tests/ChangelogUpdaterTests.cs b/tests/Buildvana.Tool.Tests/ChangelogUpdaterTests.cs new file mode 100644 index 0000000..6ba9fa0 --- /dev/null +++ b/tests/Buildvana.Tool.Tests/ChangelogUpdaterTests.cs @@ -0,0 +1,477 @@ +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Globalization; +using Buildvana.Core; +using Buildvana.Tool.Services; + +internal sealed class ChangelogUpdaterTests +{ + private const string NewSectionTitle = "[1.2.3](https://example.com/releases/tag/1.2.3) (2026-01-01)"; + + [Test] + public async Task HasUnreleasedChanges_IsFalseWhenSectionIsEmpty() + { + string[] lines = + [ + "# Changelog", + string.Empty, + "## Unreleased changes", + string.Empty, + "### New features", + string.Empty, + "## [1.2.2](https://example.com/releases/tag/1.2.2) (2025-12-01)", + string.Empty, + "- Something released.", + ]; + + await Assert.That(ChangelogUpdater.HasUnreleasedChanges(lines)).IsFalse(); + } + + [Test] + public async Task HasUnreleasedChanges_IsTrueWhenSectionHasContent() + { + string[] lines = + [ + "# Changelog", + string.Empty, + "## Unreleased changes", + string.Empty, + "### New features", + string.Empty, + "- Something new.", + string.Empty, + "## [1.2.2](https://example.com/releases/tag/1.2.2) (2025-12-01)", + ]; + + await Assert.That(ChangelogUpdater.HasUnreleasedChanges(lines)).IsTrue(); + } + + // The "Unreleased changes" section is the last one before a published release exists; + // reaching EOF without finding content is not a failure. + [Test] + public async Task HasUnreleasedChanges_IsFalseWhenEmptySectionEndsTheFile() + { + string[] lines = + [ + "# Changelog", + string.Empty, + "## Unreleased changes", + string.Empty, + "### New features", + string.Empty, + ]; + + await Assert.That(ChangelogUpdater.HasUnreleasedChanges(lines)).IsFalse(); + } + + [Test] + public async Task HasUnreleasedChanges_FailsWhenThereAreNoSections() + { + string[] lines = ["# Changelog", string.Empty, "Nothing to see here."]; + bool Act() => ChangelogUpdater.HasUnreleasedChanges(lines); + + var exception = await Assert.That(Act).Throws(); + await Assert.That(exception!.Message).IsEqualTo("CHANGELOG.md contains no sections."); + } + + [Test] + public async Task PrepareForRelease_MovesUnreleasedContentToNewSection() + { + string[] lines = + [ + "# Changelog", + string.Empty, + "## Unreleased changes", + string.Empty, + "- Something new.", + string.Empty, + "## [1.2.2](https://example.com/releases/tag/1.2.2) (2025-12-01)", + string.Empty, + "- Something released.", + ]; + + var result = PrepareForRelease(lines); + + await Assert.That(result).IsEqualTo( + """ + # Changelog + + ## Unreleased changes + + ## [1.2.3](https://example.com/releases/tag/1.2.3) (2026-01-01) + + - Something new. + + ## [1.2.2](https://example.com/releases/tag/1.2.2) (2025-12-01) + + - Something released. + + """.ReplaceLineEndings("\n")); + } + + // Subsection headings are recreated (empty) in the "Unreleased changes" section, so that the next + // release starts from the same skeleton; only those with actual content move to the new section. + [Test] + public async Task PrepareForRelease_RecreatesSubsectionsAndMovesOnlyNonEmptyOnes() + { + string[] lines = + [ + "# Changelog", + string.Empty, + "## Unreleased changes", + string.Empty, + "### New features", + string.Empty, + "- Something new.", + string.Empty, + "### Bugs fixed in this release", + string.Empty, + "## [1.2.2](https://example.com/releases/tag/1.2.2) (2025-12-01)", + ]; + + var result = PrepareForRelease(lines); + + await Assert.That(result).IsEqualTo( + """ + # Changelog + + ## Unreleased changes + + ### New features + + ### Bugs fixed in this release + + ## [1.2.3](https://example.com/releases/tag/1.2.3) (2026-01-01) + + ### New features + + - Something new. + + ## [1.2.2](https://example.com/releases/tag/1.2.2) (2025-12-01) + + """.ReplaceLineEndings("\n")); + } + + // The substitute replaces the whole body of the moved section, blank lines included, so the blank + // lines that set the new section apart are ours: however the substitute is padded in configuration, + // the section comes out spaced like any other. + [Test] + public async Task PrepareForRelease_SubstitutesConfiguredTextForEmptySection() + { + string[] lines = + [ + "# Changelog", + string.Empty, + "## Unreleased changes", + string.Empty, + "### New features", + string.Empty, + "## [1.2.2](https://example.com/releases/tag/1.2.2) (2025-12-01)", + ]; + + var result = PrepareForRelease(lines, emptyChangelogSubstitute: "\n- Maintenance release.\r\n- No user-visible changes.\r\n\n"); + + await Assert.That(result).IsEqualTo( + """ + # Changelog + + ## Unreleased changes + + ### New features + + ## [1.2.3](https://example.com/releases/tag/1.2.3) (2026-01-01) + + - Maintenance release. + - No user-visible changes. + + ## [1.2.2](https://example.com/releases/tag/1.2.2) (2025-12-01) + + """.ReplaceLineEndings("\n")); + } + + // The blank line added after the substitute is subject to the same EOF trimming as moved content. + [Test] + public async Task PrepareForRelease_SubstitutesConfiguredTextWhenNewSectionIsLast() + { + string[] lines = + [ + "# Changelog", + string.Empty, + "## Unreleased changes", + string.Empty, + ]; + + var result = PrepareForRelease(lines, emptyChangelogSubstitute: "- Maintenance release."); + + await Assert.That(result).IsEqualTo( + """ + # Changelog + + ## Unreleased changes + + ## [1.2.3](https://example.com/releases/tag/1.2.3) (2026-01-01) + + - Maintenance release. + + """.ReplaceLineEndings("\n")); + } + + // Without a substitute, an empty section moves verbatim: the new section gets a title and nothing else. + [Test] + public async Task PrepareForRelease_MovesEmptySectionVerbatimWithoutSubstitute() + { + string[] lines = + [ + "# Changelog", + string.Empty, + "## Unreleased changes", + string.Empty, + "## [1.2.2](https://example.com/releases/tag/1.2.2) (2025-12-01)", + ]; + + var result = PrepareForRelease(lines); + + await Assert.That(result).IsEqualTo( + """ + # Changelog + + ## Unreleased changes + + ## [1.2.3](https://example.com/releases/tag/1.2.3) (2026-01-01) + + ## [1.2.2](https://example.com/releases/tag/1.2.2) (2025-12-01) + + """.ReplaceLineEndings("\n")); + } + + // A substitute that is all whitespace has nothing left to contribute once trimmed. Emitting it anyway would + // write the blank lines that surround it and nothing else, i.e. three consecutive blank lines into a file + // whose linter forbids them; it is treated as no substitute at all instead. + [Test] + public async Task PrepareForRelease_IgnoresWhitespaceOnlySubstitute() + { + string[] lines = + [ + "# Changelog", + string.Empty, + "## Unreleased changes", + string.Empty, + "## [1.2.2](https://example.com/releases/tag/1.2.2) (2025-12-01)", + ]; + + var result = PrepareForRelease(lines, emptyChangelogSubstitute: " \n\t\n "); + + await Assert.That(result).IsEqualTo( + """ + # Changelog + + ## Unreleased changes + + ## [1.2.3](https://example.com/releases/tag/1.2.3) (2026-01-01) + + ## [1.2.2](https://example.com/releases/tag/1.2.2) (2025-12-01) + + """.ReplaceLineEndings("\n")); + } + + // When the new section is the last one in the file, the blank lines that separated the moved content + // from the section that followed it would end up trailing at EOF, and are trimmed. + [Test] + public async Task PrepareForRelease_TrimsTrailingBlankLinesWhenNewSectionIsLast() + { + string[] lines = + [ + "# Changelog", + string.Empty, + "## Unreleased changes", + string.Empty, + "- Something new.", + string.Empty, + string.Empty, + ]; + + var result = PrepareForRelease(lines); + + await Assert.That(result).IsEqualTo( + """ + # Changelog + + ## Unreleased changes + + ## [1.2.3](https://example.com/releases/tag/1.2.3) (2026-01-01) + + - Something new. + + """.ReplaceLineEndings("\n")); + } + + // The rewritten changelog always uses LF, whatever the line endings of the file it was read from + // (which never reach this code), of the configured substitute (which does), and of the platform it runs on. + [Test] + public async Task PrepareForRelease_UsesLineFeedAsLineSeparator() + { + string[] lines = + [ + "# Changelog", + string.Empty, + "## Unreleased changes", + string.Empty, + "## [1.2.2](https://example.com/releases/tag/1.2.2) (2025-12-01)", + ]; + + var result = PrepareForRelease(lines, emptyChangelogSubstitute: "- Maintenance release.\r\n- No user-visible changes."); + + await Assert.That(result).DoesNotContain("\r"); + await Assert.That(result).EndsWith("\n"); + } + + [Test] + public async Task PrepareForRelease_FailsWhenThereAreNoSections() + { + string[] lines = ["# Changelog", string.Empty, "Nothing to see here."]; + string Act() => PrepareForRelease(lines); + + var exception = await Assert.That(Act).Throws(); + await Assert.That(exception!.Message).IsEqualTo("CHANGELOG.md contains no sections."); + } + + // The title is only needed when a new section is actually written: a changelog that fails validation + // must report that failure, not one raised while composing a title that will never be used. + [Test] + public async Task PrepareForRelease_DoesNotMakeSectionTitleWhenThereAreNoSections() + { + string[] lines = ["# Changelog", string.Empty, "Nothing to see here."]; + var titleMade = false; + string MakeSectionTitle() + { + titleMade = true; + return NewSectionTitle; + } + + string Act() => ChangelogUpdater.PrepareForRelease(lines, MakeSectionTitle, null); + + _ = await Assert.That(Act).Throws(); + await Assert.That(titleMade).IsFalse(); + } + + [Test] + public async Task UpdateNewSectionTitle_ReplacesTitleOfSectionAfterUnreleasedChanges() + { + string[] lines = + [ + "# Changelog", + string.Empty, + "## Unreleased changes", + string.Empty, + "### New features", + string.Empty, + "## [1.2.3-preview.1](https://example.com/releases/tag/1.2.3-preview.1) (2026-01-01)", + string.Empty, + "- Something new.", + string.Empty, + "## [1.2.2](https://example.com/releases/tag/1.2.2) (2025-12-01)", + ]; + + var result = ChangelogUpdater.UpdateNewSectionTitle(lines, () => NewSectionTitle); + + await Assert.That(result).IsEqualTo( + """ + # Changelog + + ## Unreleased changes + + ### New features + + ## [1.2.3](https://example.com/releases/tag/1.2.3) (2026-01-01) + + - Something new. + + ## [1.2.2](https://example.com/releases/tag/1.2.2) (2025-12-01) + + """.ReplaceLineEndings("\n")); + } + + [Test] + public async Task UpdateNewSectionTitle_FailsWhenThereIsOnlyOneSection() + { + string[] lines = + [ + "# Changelog", + string.Empty, + "## Unreleased changes", + string.Empty, + "- Something new.", + ]; + + string Act() => ChangelogUpdater.UpdateNewSectionTitle(lines, () => NewSectionTitle); + + var exception = await Assert.That(Act).Throws(); + await Assert.That(exception!.Message).IsEqualTo("CHANGELOG.md contains only one section."); + } + + [Test] + public async Task UpdateNewSectionTitle_FailsWhenThereAreNoSections() + { + string[] lines = ["# Changelog", string.Empty, "Nothing to see here."]; + string Act() => ChangelogUpdater.UpdateNewSectionTitle(lines, () => NewSectionTitle); + + var exception = await Assert.That(Act).Throws(); + await Assert.That(exception!.Message).IsEqualTo("CHANGELOG.md contains no sections."); + } + + // Same guarantee as PrepareForRelease: the title is composed only when a heading is actually replaced. + [Test] + public async Task UpdateNewSectionTitle_DoesNotMakeSectionTitleWhenThereAreNoSections() + { + string[] lines = ["# Changelog", string.Empty, "Nothing to see here."]; + var titleMade = false; + string MakeSectionTitle() + { + titleMade = true; + return NewSectionTitle; + } + + string Act() => ChangelogUpdater.UpdateNewSectionTitle(lines, MakeSectionTitle); + + _ = await Assert.That(Act).Throws(); + await Assert.That(titleMade).IsFalse(); + } + + [Test] + public async Task MakeSectionTitle_ComposesVersionUrlAndDate() + { + var title = ComposeSectionTitle(); + + await Assert.That(title).IsEqualTo(NewSectionTitle); + } + + // The title ends up in a file that is read, and released from, on every machine: the date must not follow + // the calendar of whoever runs the release. Formatted with the current culture, it would read 2569-01-01 + // under th-TH, matching neither the release tag nor the other section titles. + // This test needs ICU data to bite: with InvariantGlobalization enabled, th-TH would fall back to the + // Gregorian calendar and the assertion would pass whatever culture MakeSectionTitle formatted with. + [Test] + [NotInParallel] + public async Task MakeSectionTitle_UsesInvariantCalendar_WhateverTheCurrentCulture() + { + var savedCulture = CultureInfo.CurrentCulture; + try + { + CultureInfo.CurrentCulture = new CultureInfo("th-TH"); + var title = ComposeSectionTitle(); + + await Assert.That(title).IsEqualTo(NewSectionTitle); + } + finally + { + CultureInfo.CurrentCulture = savedCulture; + } + } + + private static string ComposeSectionTitle() + => ChangelogUpdater.MakeSectionTitle("1.2.3", new Uri("https://example.com/releases/tag/1.2.3"), new DateTime(2026, 1, 1)); + + private static string PrepareForRelease(string[] lines, string? emptyChangelogSubstitute = null) + => ChangelogUpdater.PrepareForRelease(lines, () => NewSectionTitle, emptyChangelogSubstitute); +} diff --git a/tests/Buildvana.Tool.Tests/ReleaseSettingsTests.cs b/tests/Buildvana.Tool.Tests/ReleaseSettingsTests.cs index c2039ad..505f8b5 100644 --- a/tests/Buildvana.Tool.Tests/ReleaseSettingsTests.cs +++ b/tests/Buildvana.Tool.Tests/ReleaseSettingsTests.cs @@ -69,6 +69,15 @@ public async Task Resolve_ReadsReleaseConfig_WhenFlagsAbsent() await Assert.That(settings.ResolveEmptyChangelog()).IsEqualTo("Nothing to see here."); } + // Substituting whitespace for an empty changelog would substitute nothing for nothing, so it counts + // as no substitute at all and lets the release fail with the message that says so. + [Test] + public async Task ResolveEmptyChangelog_IsNull_WhenConfiguredTextIsAllWhitespace() + { + var config = new BuildvanaConfig { Release = new() { EmptyChangelog = " \n\t " } }; + await Assert.That(Parse([], config).ResolveEmptyChangelog()).IsNull(); + } + [Test] public async Task Resolve_FlagsWin_OverReleaseConfig() {