Skip to content

Commit

Permalink
Update build scripts
Browse files Browse the repository at this point in the history
  • Loading branch information
rdeago committed Oct 26, 2023
1 parent 5c1b362 commit 8d9e559
Show file tree
Hide file tree
Showing 4 changed files with 310 additions and 283 deletions.
25 changes: 15 additions & 10 deletions build.cake
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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<bool>("forceUpdateChangelog", false))
if (!changelog.Exists)
{
context.Information($"Changelog update skipped: {Changelog.FileName} not found.");
}
else if (!data.IsPrerelease || context.GetOption<bool>("forceUpdateChangelog", false))
{
if (context.GetOption<bool>("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
Expand Down Expand Up @@ -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
{
Expand Down
7 changes: 0 additions & 7 deletions build/BuildData.cake
Original file line number Diff line number Diff line change
Expand Up @@ -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<FilePath>(255, "Cannot find a solution file.");
var solution = context.ParseSolution(solutionPath);
var configuration = context.Argument("configuration", "Release");
Expand Down Expand Up @@ -58,7 +57,6 @@ sealed class BuildData
Branch = branch;
ArtifactsPath = artifactsPath;
TestResultsPath = testResultsPath;
ChangelogPath = changelogPath;
SolutionPath = solutionPath;
Solution = solution;
Configuration = configuration;
Expand Down Expand Up @@ -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.
*/
Expand Down
295 changes: 295 additions & 0 deletions build/Changelog.cake
Original file line number Diff line number Diff line change
@@ -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<string> Lines)>();
subSections.Add(("", new List<string>()));
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<string>()));
++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<string> CollectNewSectionLines()
{
var result = new List<string>(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})";
}
}
Loading

0 comments on commit 8d9e559

Please sign in to comment.