Skip to content

Commit 8d9e559

Browse files
committed
Update build scripts
1 parent 5c1b362 commit 8d9e559

File tree

4 files changed

+310
-283
lines changed

4 files changed

+310
-283
lines changed

build.cake

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
// See LICENSE file in the project root for full license information.
33

44
#load "./build/BuildData.cake"
5-
#load "./build/changelog.cake"
5+
#load "./build/Changelog.cake"
66
#load "./build/dotnet.cake"
77
#load "./build/environment.cake"
88
#load "./build/fail.cake"
@@ -133,26 +133,31 @@ Task("Release")
133133
}
134134

135135
// Update changelog only on non-prerelease, unless forced
136+
var changelog = new Changelog(context, data);
136137
var changelogUpdated = false;
137-
if (!data.IsPrerelease || context.GetOption<bool>("forceUpdateChangelog", false))
138+
if (!changelog.Exists)
139+
{
140+
context.Information($"Changelog update skipped: {Changelog.FileName} not found.");
141+
}
142+
else if (!data.IsPrerelease || context.GetOption<bool>("forceUpdateChangelog", false))
138143
{
139144
if (context.GetOption<bool>("checkChangelog", true))
140145
{
141146
context.Ensure(
142-
context.ChangelogHasUnreleasedChanges(data.ChangelogPath),
143-
$"Changelog check failed: the \"Unreleased changes\" section is empty or only contains sub-section headings.");
147+
changelog.HasUnreleasedChanges(),
148+
"Changelog check failed: the \"Unreleased changes\" section is empty or only contains sub-section headings.");
144149

145-
context.Information($"Changelog check successful: the \"Unreleased changes\" section is not empty.");
150+
context.Information("Changelog check successful: the \"Unreleased changes\" section is not empty.");
146151
}
147152
else
148153
{
149-
context.Information($"Changelog check skipped: option 'checkChangelog' is false.");
154+
context.Information("Changelog check skipped: option 'checkChangelog' is false.");
150155
}
151156

152157
// Update the changelog and commit the change before building.
153158
// This ensures that the Git height is up to date when computing a version for the build artifacts.
154-
context.PrepareChangelogForRelease(data);
155-
UpdateRepo(data.ChangelogPath);
159+
changelog.PrepareForRelease();
160+
UpdateRepo(changelog.Path);
156161
changelogUpdated = true;
157162
}
158163
else
@@ -182,8 +187,8 @@ Task("Release")
182187
if (changelogUpdated)
183188
{
184189
// Change the new section's title in the changelog to reflect the actual version.
185-
context.UpdateChangelogNewSectionTitle(data);
186-
UpdateRepo(data.ChangelogPath);
190+
changelog.UpdateNewSectionTitle();
191+
UpdateRepo(changelog.Path);
187192
}
188193
else
189194
{

build/BuildData.cake

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@ sealed class BuildData
2121
public BuildData(ICakeContext context)
2222
{
2323
context.Ensure(context.TryGetRepositoryInfo(out var repository), 255, "Cannot determine repository owner and name.");
24-
var changelogPath = new FilePath("CHANGELOG.md");
2524
var solutionPath = context.GetFiles("*.sln").FirstOrDefault() ?? context.Fail<FilePath>(255, "Cannot find a solution file.");
2625
var solution = context.ParseSolution(solutionPath);
2726
var configuration = context.Argument("configuration", "Release");
@@ -58,7 +57,6 @@ sealed class BuildData
5857
Branch = branch;
5958
ArtifactsPath = artifactsPath;
6059
TestResultsPath = testResultsPath;
61-
ChangelogPath = changelogPath;
6260
SolutionPath = solutionPath;
6361
Solution = solution;
6462
Configuration = configuration;
@@ -125,11 +123,6 @@ sealed class BuildData
125123
*/
126124
public DirectoryPath TestResultsPath { get; }
127125

128-
/*
129-
* Summary : Gets the path of the CHANGELOG.md file.
130-
*/
131-
public FilePath ChangelogPath { get; }
132-
133126
/*
134127
* Summary : Gets the path of the solution file.
135128
*/

build/Changelog.cake

Lines changed: 295 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,295 @@
1+
// Copyright (C) Tenacom and contributors. Licensed under the MIT license.
2+
// See LICENSE file in the project root for full license information.
3+
4+
#nullable enable
5+
6+
// ---------------------------------------------------------------------------------------------
7+
// Changelog management helpers
8+
// ---------------------------------------------------------------------------------------------
9+
10+
using System.Globalization;
11+
using System.IO;
12+
using System.Text;
13+
using System.Text.RegularExpressions;
14+
15+
using SysFile = System.IO.File;
16+
17+
sealed class Changelog
18+
{
19+
public const string FileName = "CHANGELOG.md";
20+
21+
private readonly ICakeContext _context;
22+
private readonly BuildData _data;
23+
24+
/*
25+
* Summary : Initializes a new instance of class Changelog.
26+
* Params : context - The Cake context.
27+
*/
28+
public Changelog(ICakeContext context, BuildData data)
29+
{
30+
_context = context;
31+
_data = data;
32+
Path = new FilePath(FileName);
33+
FullPath = Path.FullPath;
34+
Exists = SysFile.Exists(FullPath);
35+
}
36+
37+
public FilePath Path { get; }
38+
39+
public string FullPath { get; }
40+
41+
public bool Exists { get; }
42+
43+
/*
44+
* Summary : Checks the changelog for contents in the "Unreleased changes" section.
45+
* Params : (none)
46+
* Returns : If there are any contents (excluding blank lines and sub-section headings)
47+
* in the "Unreleased changes" section, true; otherwise, false.
48+
*/
49+
public bool HasUnreleasedChanges()
50+
{
51+
if (!Exists)
52+
{
53+
return false;
54+
}
55+
56+
using (var reader = new StreamReader(FullPath, Encoding.UTF8))
57+
{
58+
var sectionHeadingRegex = new Regex(@"^ {0,3}##($|[^#])", RegexOptions.Compiled | RegexOptions.CultureInvariant);
59+
var subSectionHeadingRegex = new Regex(@"^ {0,3}###($|[^#])", RegexOptions.Compiled | RegexOptions.CultureInvariant);
60+
string? line;
61+
do
62+
{
63+
line = reader.ReadLine();
64+
} while (line != null && !sectionHeadingRegex.IsMatch(line));
65+
66+
Ensure(_context, line != null, $"{FileName} contains no sections.");
67+
for (; ;)
68+
{
69+
line = reader.ReadLine();
70+
if (line == null || sectionHeadingRegex.IsMatch(line))
71+
{
72+
break;
73+
}
74+
75+
if (!string.IsNullOrWhiteSpace(line) && !subSectionHeadingRegex.IsMatch(line))
76+
{
77+
return true;
78+
}
79+
}
80+
}
81+
82+
return false;
83+
}
84+
85+
/*
86+
* Summary : Prepares the changelog for a release by moving the contents of the "Unreleased changes" section
87+
* to a new section.
88+
* Params : (none)
89+
*/
90+
public void PrepareForRelease()
91+
{
92+
_context.Information("Updating changelog...");
93+
var encoding = new UTF8Encoding(false, true);
94+
var sb = new StringBuilder();
95+
using (var reader = new StreamReader(FullPath, encoding))
96+
using (var writer = new StringWriter(sb, CultureInfo.InvariantCulture))
97+
{
98+
// Using a StringWriter instead of a StringBuilder allows for a custom line separator
99+
// Under Windows, a StringBuilder would only use "\r\n" as a line separator, which would be wrong in this case
100+
writer.NewLine = "\n";
101+
var sectionHeadingRegex = new Regex(@"^ {0,3}##($|[^#])", RegexOptions.Compiled | RegexOptions.CultureInvariant);
102+
var subSectionHeadingRegex = new Regex(@"^ {0,3}###($|[^#])", RegexOptions.Compiled | RegexOptions.CultureInvariant);
103+
var subSections = new List<(string Header, List<string> Lines)>();
104+
subSections.Add(("", new List<string>()));
105+
var subSectionIndex = 0;
106+
107+
const int ReadingFileHeader = 0;
108+
const int ReadingUnreleasedChangesSection = 1;
109+
const int ReadingRemainderOfFile = 2;
110+
const int ReadingDone = 3;
111+
var state = ReadingFileHeader;
112+
while (state != ReadingDone)
113+
{
114+
var line = reader.ReadLine();
115+
switch (state)
116+
{
117+
case ReadingFileHeader:
118+
Ensure(_context, line != null, $"{FileName} contains no sections.");
119+
120+
// Copy everything up to an including the first section heading (which we assume is "Unreleased changes")
121+
writer.WriteLine(line);
122+
if (sectionHeadingRegex.IsMatch(line))
123+
{
124+
state = ReadingUnreleasedChangesSection;
125+
}
126+
127+
break;
128+
case ReadingUnreleasedChangesSection:
129+
if (line == null)
130+
{
131+
// The changelog only contains the "Unreleased changes" section;
132+
// this happens when no release has been published yet
133+
WriteNewSections(true);
134+
state = ReadingDone;
135+
break;
136+
}
137+
138+
if (sectionHeadingRegex.IsMatch(line))
139+
{
140+
// Reached header of next section
141+
WriteNewSections(false);
142+
writer.WriteLine(line);
143+
state = ReadingRemainderOfFile;
144+
break;
145+
}
146+
147+
if (subSectionHeadingRegex.IsMatch(line))
148+
{
149+
subSections.Add((line, new List<string>()));
150+
++subSectionIndex;
151+
break;
152+
}
153+
154+
subSections[subSectionIndex].Lines.Add(line);
155+
break;
156+
case ReadingRemainderOfFile:
157+
if (line == null)
158+
{
159+
state = ReadingDone;
160+
break;
161+
}
162+
163+
writer.WriteLine(line);
164+
break;
165+
default:
166+
Fail(_context, $"Internal error: reading state corrupted ({state}).");
167+
throw null;
168+
}
169+
}
170+
171+
void WriteNewSections(bool atEndOfFile)
172+
{
173+
// Create empty sub-sections in new "Unreleased changes" section
174+
foreach (var subSection in subSections.Skip(1))
175+
{
176+
writer.WriteLine(string.Empty);
177+
writer.WriteLine(subSection.Header);
178+
}
179+
180+
// Write header of new release section
181+
writer.WriteLine(string.Empty);
182+
writer.WriteLine("## " + MakeSectionTitle());
183+
184+
var newSectionLines = CollectNewSectionLines();
185+
var newSectionCount = newSectionLines.Count;
186+
if (atEndOfFile)
187+
{
188+
// If there is no other section after the new release,
189+
// we don't want extra blank lines at EOF
190+
while (newSectionCount > 0 && string.IsNullOrEmpty(newSectionLines[newSectionCount - 1]))
191+
{
192+
--newSectionCount;
193+
}
194+
}
195+
196+
foreach (var newSectionLine in newSectionLines.Take(newSectionCount))
197+
{
198+
writer.WriteLine(newSectionLine);
199+
}
200+
}
201+
202+
List<string> CollectNewSectionLines()
203+
{
204+
var result = new List<string>(subSections[0].Lines);
205+
206+
// Copy only sub-sections that have actual content
207+
foreach (var subSection in subSections.Skip(1).Where(s => s.Lines.Any(l => !string.IsNullOrWhiteSpace(l))))
208+
{
209+
result.Add(subSection.Header);
210+
foreach (var contentLine in subSection.Lines)
211+
{
212+
result.Add(contentLine);
213+
}
214+
}
215+
216+
return result;
217+
}
218+
}
219+
220+
SysFile.WriteAllText(FullPath, sb.ToString(), encoding);
221+
}
222+
223+
/*
224+
* Summary : Updates the heading of the first section of the changelog after the "Unreleased changes" section
225+
* to reflect a change in the released version.
226+
* Params : (none)
227+
*/
228+
public void UpdateNewSectionTitle()
229+
{
230+
_context.Information("Updating changelog's new release section title...");
231+
var encoding = new UTF8Encoding(false, true);
232+
var sb = new StringBuilder();
233+
using (var reader = new StreamReader(FullPath, encoding))
234+
using (var writer = new StringWriter(sb, CultureInfo.InvariantCulture))
235+
{
236+
// Using a StringWriter instead of a StringBuilder allows for a custom line separator
237+
// Under Windows, a StringBuilder would only use "\r\n" as a line separator, which would be wrong in this case
238+
writer.NewLine = "\n";
239+
var sectionHeadingRegex = new Regex(@"^ {0,3}##($|[^#])", RegexOptions.Compiled | RegexOptions.CultureInvariant);
240+
241+
const int ReadingFileHeader = 0;
242+
const int ReadingUnreleasedChangesSection = 1;
243+
const int ReadingRemainderOfFile = 2;
244+
const int ReadingDone = 3;
245+
var state = ReadingFileHeader;
246+
while (state != ReadingDone)
247+
{
248+
var line = reader.ReadLine();
249+
switch (state)
250+
{
251+
case ReadingFileHeader:
252+
Ensure(_context, line != null, $"{FileName} contains no sections.");
253+
writer.WriteLine(line);
254+
if (sectionHeadingRegex.IsMatch(line))
255+
{
256+
state = ReadingUnreleasedChangesSection;
257+
}
258+
259+
break;
260+
case ReadingUnreleasedChangesSection:
261+
Ensure(_context, line != null, $"{FileName} contains only one section.");
262+
if (sectionHeadingRegex.IsMatch(line))
263+
{
264+
// Replace header of second section
265+
writer.WriteLine("## " + MakeSectionTitle());
266+
state = ReadingRemainderOfFile;
267+
break;
268+
}
269+
270+
writer.WriteLine(line);
271+
break;
272+
case ReadingRemainderOfFile:
273+
if (line == null)
274+
{
275+
state = ReadingDone;
276+
break;
277+
}
278+
279+
writer.WriteLine(line);
280+
break;
281+
default:
282+
Fail(_context, $"Internal error: reading state corrupted ({state}).");
283+
throw null;
284+
}
285+
}
286+
}
287+
288+
SysFile.WriteAllText(FullPath, sb.ToString(), encoding);
289+
}
290+
291+
private string MakeSectionTitle()
292+
{
293+
return $"[{_data.VersionStr}](https://github.com/{_data.RepositoryOwner}/{_data.RepositoryName}/releases/tag/{_data.VersionStr}) ({DateTime.Now:yyyy-MM-dd})";
294+
}
295+
}

0 commit comments

Comments
 (0)