diff --git a/build.cake b/build.cake index 27340d2..89017a9 100644 --- a/build.cake +++ b/build.cake @@ -2,7 +2,7 @@ // See LICENSE file in the project root for full license information. #load "./build/BuildData.cake" -#load "./build/changelog.cake" +#load "./build/Changelog.cake" #load "./build/dotnet.cake" #load "./build/environment.cake" #load "./build/fail.cake" @@ -133,26 +133,31 @@ Task("Release") } // Update changelog only on non-prerelease, unless forced + var changelog = new Changelog(context, data); var changelogUpdated = false; - if (!data.IsPrerelease || context.GetOption("forceUpdateChangelog", false)) + if (!changelog.Exists) + { + context.Information($"Changelog update skipped: {Changelog.FileName} not found."); + } + else if (!data.IsPrerelease || context.GetOption("forceUpdateChangelog", false)) { if (context.GetOption("checkChangelog", true)) { context.Ensure( - context.ChangelogHasUnreleasedChanges(data.ChangelogPath), - $"Changelog check failed: the \"Unreleased changes\" section is empty or only contains sub-section headings."); + changelog.HasUnreleasedChanges(), + "Changelog check failed: the \"Unreleased changes\" section is empty or only contains sub-section headings."); - context.Information($"Changelog check successful: the \"Unreleased changes\" section is not empty."); + context.Information("Changelog check successful: the \"Unreleased changes\" section is not empty."); } else { - context.Information($"Changelog check skipped: option 'checkChangelog' is false."); + context.Information("Changelog check skipped: option 'checkChangelog' is false."); } // Update the changelog and commit the change before building. // This ensures that the Git height is up to date when computing a version for the build artifacts. - context.PrepareChangelogForRelease(data); - UpdateRepo(data.ChangelogPath); + changelog.PrepareForRelease(); + UpdateRepo(changelog.Path); changelogUpdated = true; } else @@ -182,8 +187,8 @@ Task("Release") if (changelogUpdated) { // Change the new section's title in the changelog to reflect the actual version. - context.UpdateChangelogNewSectionTitle(data); - UpdateRepo(data.ChangelogPath); + changelog.UpdateNewSectionTitle(); + UpdateRepo(changelog.Path); } else { diff --git a/build/BuildData.cake b/build/BuildData.cake index df85870..ecdf31b 100644 --- a/build/BuildData.cake +++ b/build/BuildData.cake @@ -21,7 +21,6 @@ sealed class BuildData public BuildData(ICakeContext context) { context.Ensure(context.TryGetRepositoryInfo(out var repository), 255, "Cannot determine repository owner and name."); - var changelogPath = new FilePath("CHANGELOG.md"); var solutionPath = context.GetFiles("*.sln").FirstOrDefault() ?? context.Fail(255, "Cannot find a solution file."); var solution = context.ParseSolution(solutionPath); var configuration = context.Argument("configuration", "Release"); @@ -58,7 +57,6 @@ sealed class BuildData Branch = branch; ArtifactsPath = artifactsPath; TestResultsPath = testResultsPath; - ChangelogPath = changelogPath; SolutionPath = solutionPath; Solution = solution; Configuration = configuration; @@ -125,11 +123,6 @@ sealed class BuildData */ public DirectoryPath TestResultsPath { get; } - /* - * Summary : Gets the path of the CHANGELOG.md file. - */ - public FilePath ChangelogPath { get; } - /* * Summary : Gets the path of the solution file. */ diff --git a/build/Changelog.cake b/build/Changelog.cake new file mode 100644 index 0000000..1c7a9f0 --- /dev/null +++ b/build/Changelog.cake @@ -0,0 +1,295 @@ +// Copyright (C) Tenacom and contributors. Licensed under the MIT license. +// See LICENSE file in the project root for full license information. + +#nullable enable + +// --------------------------------------------------------------------------------------------- +// Changelog management helpers +// --------------------------------------------------------------------------------------------- + +using System.Globalization; +using System.IO; +using System.Text; +using System.Text.RegularExpressions; + +using SysFile = System.IO.File; + +sealed class Changelog +{ + public const string FileName = "CHANGELOG.md"; + + private readonly ICakeContext _context; + private readonly BuildData _data; + + /* + * Summary : Initializes a new instance of class Changelog. + * Params : context - The Cake context. + */ + public Changelog(ICakeContext context, BuildData data) + { + _context = context; + _data = data; + Path = new FilePath(FileName); + FullPath = Path.FullPath; + Exists = SysFile.Exists(FullPath); + } + + public FilePath Path { get; } + + public string FullPath { get; } + + public bool Exists { get; } + + /* + * Summary : Checks the changelog for contents in the "Unreleased changes" section. + * Params : (none) + * Returns : If there are any contents (excluding blank lines and sub-section headings) + * in the "Unreleased changes" section, true; otherwise, false. + */ + public bool HasUnreleasedChanges() + { + if (!Exists) + { + return false; + } + + using (var reader = new StreamReader(FullPath, Encoding.UTF8)) + { + var sectionHeadingRegex = new Regex(@"^ {0,3}##($|[^#])", RegexOptions.Compiled | RegexOptions.CultureInvariant); + var subSectionHeadingRegex = new Regex(@"^ {0,3}###($|[^#])", RegexOptions.Compiled | RegexOptions.CultureInvariant); + string? line; + do + { + line = reader.ReadLine(); + } while (line != null && !sectionHeadingRegex.IsMatch(line)); + + Ensure(_context, 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; + } + + /* + * Summary : Prepares the changelog for a release by moving the contents of the "Unreleased changes" section + * to a new section. + * Params : (none) + */ + public void PrepareForRelease() + { + _context.Information("Updating changelog..."); + var encoding = new UTF8Encoding(false, true); + var sb = new StringBuilder(); + using (var reader = new StreamReader(FullPath, 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 = new Regex(@"^ {0,3}##($|[^#])", RegexOptions.Compiled | RegexOptions.CultureInvariant); + var subSectionHeadingRegex = new Regex(@"^ {0,3}###($|[^#])", RegexOptions.Compiled | RegexOptions.CultureInvariant); + var subSections = new List<(string Header, List Lines)>(); + subSections.Add(("", new List())); + 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: + Ensure(_context, 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, new List())); + ++subSectionIndex; + break; + } + + subSections[subSectionIndex].Lines.Add(line); + break; + case ReadingRemainderOfFile: + if (line == null) + { + state = ReadingDone; + break; + } + + writer.WriteLine(line); + break; + default: + Fail(_context, $"Internal error: reading state corrupted ({state})."); + throw null; + } + } + + void WriteNewSections(bool atEndOfFile) + { + // Create empty sub-sections in new "Unreleased changes" section + foreach (var subSection in subSections.Skip(1)) + { + writer.WriteLine(string.Empty); + writer.WriteLine(subSection.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 sub-sections that have actual content + foreach (var subSection in subSections.Skip(1).Where(s => s.Lines.Any(l => !string.IsNullOrWhiteSpace(l)))) + { + result.Add(subSection.Header); + foreach (var contentLine in subSection.Lines) + { + result.Add(contentLine); + } + } + + return result; + } + } + + SysFile.WriteAllText(FullPath, sb.ToString(), encoding); + } + + /* + * Summary : Updates the heading of the first section of the changelog after the "Unreleased changes" section + * to reflect a change in the released version. + * Params : (none) + */ + public void UpdateNewSectionTitle() + { + _context.Information("Updating changelog's new release section title..."); + var encoding = new UTF8Encoding(false, true); + var sb = new StringBuilder(); + using (var reader = new StreamReader(FullPath, 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 = new Regex(@"^ {0,3}##($|[^#])", RegexOptions.Compiled | RegexOptions.CultureInvariant); + + 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: + Ensure(_context, line != null, $"{FileName} contains no sections."); + writer.WriteLine(line); + if (sectionHeadingRegex.IsMatch(line)) + { + state = ReadingUnreleasedChangesSection; + } + + break; + case ReadingUnreleasedChangesSection: + Ensure(_context, 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; + default: + Fail(_context, $"Internal error: reading state corrupted ({state})."); + throw null; + } + } + } + + SysFile.WriteAllText(FullPath, sb.ToString(), encoding); + } + + private string MakeSectionTitle() + { + return $"[{_data.VersionStr}](https://github.com/{_data.RepositoryOwner}/{_data.RepositoryName}/releases/tag/{_data.VersionStr}) ({DateTime.Now:yyyy-MM-dd})"; + } +} diff --git a/build/changelog.cake b/build/changelog.cake deleted file mode 100644 index 1d90171..0000000 --- a/build/changelog.cake +++ /dev/null @@ -1,266 +0,0 @@ -// Copyright (C) Tenacom and contributors. Licensed under the MIT license. -// See LICENSE file in the project root for full license information. - -#nullable enable - -// --------------------------------------------------------------------------------------------- -// Changelog management helpers -// --------------------------------------------------------------------------------------------- - -using System.Globalization; -using System.IO; -using System.Text; -using System.Text.RegularExpressions; - -using SysFile = System.IO.File; - -/* - * Summary : Checks the changelog for contents in the "Unreleased changes" section. - * Params : context - The Cake context. - * changelogPath - The FilePath of the changelog. - * Returns : If there are any contents (excluding blank lines and sub-section headings) - * in the "Unreleased changes" section, true; otherwise, false. - */ -static bool ChangelogHasUnreleasedChanges(this ICakeContext context, FilePath changelogPath) -{ - using (var reader = new StreamReader(changelogPath.FullPath, Encoding.UTF8)) - { - var sectionHeadingRegex = new Regex(@"^ {0,3}##($|[^#])", RegexOptions.Compiled | RegexOptions.CultureInvariant); - var subSectionHeadingRegex = new Regex(@"^ {0,3}###($|[^#])", RegexOptions.Compiled | RegexOptions.CultureInvariant); - string? line; - do - { - line = reader.ReadLine(); - } while (line != null && !sectionHeadingRegex.IsMatch(line)); - - context.Ensure(line != null, $"{changelogPath.GetFilename()} 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; -} - -/* - * Summary : Prepares the changelog for a release by moving the contents of the "Unreleased changes" section - * to a new section. - * Params : context - The Cake context. - * data - Build configuration data. - */ -static void PrepareChangelogForRelease(this ICakeContext context, BuildData data) -{ - context.Information("Updating changelog..."); - var encoding = new UTF8Encoding(false, true); - var sb = new StringBuilder(); - using (var reader = new StreamReader(data.ChangelogPath.FullPath, 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 = new Regex(@"^ {0,3}##($|[^#])", RegexOptions.Compiled | RegexOptions.CultureInvariant); - var subSectionHeadingRegex = new Regex(@"^ {0,3}###($|[^#])", RegexOptions.Compiled | RegexOptions.CultureInvariant); - var subSections = new List<(string Header, List Lines)>(); - subSections.Add(("", new List())); - 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: - context.Ensure(line != null, $"{data.ChangelogPath.GetFilename()} 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, new List())); - ++subSectionIndex; - break; - } - - subSections[subSectionIndex].Lines.Add(line); - break; - case ReadingRemainderOfFile: - if (line == null) - { - state = ReadingDone; - break; - } - - writer.WriteLine(line); - break; - default: - context.Fail($"Internal error: reading state corrupted ({state})."); - throw null; - } - } - - void WriteNewSections(bool atEndOfFile) - { - // Create empty sub-sections in new "Unreleased changes" section - foreach (var subSection in subSections.Skip(1)) - { - writer.WriteLine(string.Empty); - writer.WriteLine(subSection.Header); - } - - // Write header of new release section - writer.WriteLine(string.Empty); - writer.WriteLine("## " + MakeChangelogSectionTitle(data)); - - 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 sub-sections that have actual content - foreach (var subSection in subSections.Skip(1).Where(s => s.Lines.Any(l => !string.IsNullOrWhiteSpace(l)))) - { - result.Add(subSection.Header); - foreach (var contentLine in subSection.Lines) - { - result.Add(contentLine); - } - } - - return result; - } - } - - SysFile.WriteAllText(data.ChangelogPath.FullPath, sb.ToString(), encoding); -} - -/* - * Summary : Updates the heading of the first section of the changelog after the "Unreleased changes" section - * to reflect a change in the released version. - * Params : context - The Cake context. - * data - Build configuratiohn data. - */ -static void UpdateChangelogNewSectionTitle(this ICakeContext context, BuildData data) -{ - context.Information("Updating changelog's new release section title..."); - var encoding = new UTF8Encoding(false, true); - var sb = new StringBuilder(); - using (var reader = new StreamReader(data.ChangelogPath.FullPath, 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 = new Regex(@"^ {0,3}##($|[^#])", RegexOptions.Compiled | RegexOptions.CultureInvariant); - - 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: - context.Ensure(line != null, $"{data.ChangelogPath.GetFilename()} contains no sections."); - writer.WriteLine(line); - if (sectionHeadingRegex.IsMatch(line)) - { - state = ReadingUnreleasedChangesSection; - } - - break; - case ReadingUnreleasedChangesSection: - context.Ensure(line != null, $"{data.ChangelogPath.GetFilename()} contains only one section."); - if (sectionHeadingRegex.IsMatch(line)) - { - // Replace header of second section - writer.WriteLine("## " + MakeChangelogSectionTitle(data)); - state = ReadingRemainderOfFile; - break; - } - - writer.WriteLine(line); - break; - case ReadingRemainderOfFile: - if (line == null) - { - state = ReadingDone; - break; - } - - writer.WriteLine(line); - break; - default: - context.Fail($"Internal error: reading state corrupted ({state})."); - throw null; - } - } - } - - SysFile.WriteAllText(data.ChangelogPath.FullPath, sb.ToString(), encoding); -} - -static string MakeChangelogSectionTitle(BuildData data) -{ - return $"[{data.VersionStr}](https://github.com/{data.RepositoryOwner}/{data.RepositoryName}/releases/tag/{data.VersionStr}) ({DateTime.Now:yyyy-MM-dd})"; -}