diff --git a/KtsuBuild.Tests/Metadata/LicenseGeneratorTests.cs b/KtsuBuild.Tests/Metadata/LicenseGeneratorTests.cs
index 89dcd4c..3dd508f 100644
--- a/KtsuBuild.Tests/Metadata/LicenseGeneratorTests.cs
+++ b/KtsuBuild.Tests/Metadata/LicenseGeneratorTests.cs
@@ -167,4 +167,65 @@ await LicenseGenerator.GenerateAsync(
Assert.IsTrue(content.Contains("WITHOUT WARRANTY"), "Should contain warranty disclaimer");
Assert.IsTrue(content.Contains("THE SOFTWARE IS PROVIDED"), "Should contain software provision clause");
}
+
+ [TestMethod]
+ public async Task GenerateAsync_NewYear_KeepsExistingCopyright()
+ {
+ // Arrange
+ const string Existing = "Copyright (c) 2023-2026 testowner contributors";
+ await File.WriteAllTextAsync(Path.Combine(_tempDir, "COPYRIGHT.md"), Existing + "\n").ConfigureAwait(false);
+
+ // Act
+ await LicenseGenerator.GenerateAsync(
+ serverUrl: "https://github.com/testowner",
+ owner: "testowner",
+ repository: "testrepo",
+ outputPath: _tempDir,
+ lineEnding: "\n",
+ currentYear: 2027).ConfigureAwait(false);
+
+ // Assert
+ string copyrightContent = await File.ReadAllTextAsync(Path.Combine(_tempDir, "COPYRIGHT.md")).ConfigureAwait(false);
+ string licenseContent = await File.ReadAllTextAsync(Path.Combine(_tempDir, "LICENSE.md")).ConfigureAwait(false);
+ Assert.AreEqual(Existing + "\n", copyrightContent, "An existing COPYRIGHT.md must not move to the new year");
+ Assert.IsTrue(licenseContent.Contains(Existing), "LICENSE.md should carry the existing copyright line");
+ Assert.IsFalse(licenseContent.Contains("2027"), "LICENSE.md should not move to the new year");
+ }
+
+ [TestMethod]
+ public async Task GenerateAsync_NoCopyrightFile_GeneratesOneForTheCurrentYear()
+ {
+ // Act
+ await LicenseGenerator.GenerateAsync(
+ serverUrl: "https://github.com/testowner",
+ owner: "testowner",
+ repository: "testrepo",
+ outputPath: _tempDir,
+ lineEnding: "\n",
+ currentYear: 2027).ConfigureAwait(false);
+
+ // Assert
+ string copyrightContent = await File.ReadAllTextAsync(Path.Combine(_tempDir, "COPYRIGHT.md")).ConfigureAwait(false);
+ Assert.AreEqual("Copyright (c) 2023-2027 testowner contributors\n", copyrightContent);
+ }
+
+ [TestMethod]
+ public async Task GenerateAsync_EmptyCopyrightFile_IsRegenerated()
+ {
+ // Arrange
+ await File.WriteAllTextAsync(Path.Combine(_tempDir, "COPYRIGHT.md"), " \n").ConfigureAwait(false);
+
+ // Act
+ await LicenseGenerator.GenerateAsync(
+ serverUrl: "https://github.com/testowner",
+ owner: "testowner",
+ repository: "testrepo",
+ outputPath: _tempDir,
+ lineEnding: "\n",
+ currentYear: 2027).ConfigureAwait(false);
+
+ // Assert
+ string copyrightContent = await File.ReadAllTextAsync(Path.Combine(_tempDir, "COPYRIGHT.md")).ConfigureAwait(false);
+ Assert.AreEqual("Copyright (c) 2023-2027 testowner contributors\n", copyrightContent);
+ }
}
diff --git a/KtsuBuild/Metadata/LicenseGenerator.cs b/KtsuBuild/Metadata/LicenseGenerator.cs
index e090439..c84b5d0 100644
--- a/KtsuBuild/Metadata/LicenseGenerator.cs
+++ b/KtsuBuild/Metadata/LicenseGenerator.cs
@@ -16,6 +16,12 @@ public static class LicenseGenerator
///
/// Generates LICENSE.md and COPYRIGHT.md files.
///
+ ///
+ /// An existing COPYRIGHT.md is kept as it is, and its text is what LICENSE.md carries. ktsu.Sdk derives
+ /// every source file's required header from COPYRIGHT.md, so rewriting it from the clock would fail
+ /// every file in the repository against IDE0073 the first time a run lands in a new year. Moving the
+ /// year on is a deliberate change that also rewrites the headers, not a side effect of a build.
+ ///
/// The GitHub server URL.
/// The repository owner.
/// The repository name.
@@ -29,6 +35,26 @@ public static async Task GenerateAsync(
string repository,
string outputPath,
string lineEnding,
+ CancellationToken cancellationToken = default) =>
+ await GenerateAsync(serverUrl, owner, repository, outputPath, lineEnding, DateTime.UtcNow.Year, cancellationToken).ConfigureAwait(false);
+
+ ///
+ /// Generates LICENSE.md and COPYRIGHT.md files, taking the current year from the caller.
+ ///
+ /// The GitHub server URL.
+ /// The repository owner.
+ /// The repository name.
+ /// The output directory.
+ /// The line ending to use.
+ /// The year a newly created COPYRIGHT.md runs to.
+ /// A cancellation token.
+ internal static async Task GenerateAsync(
+ string serverUrl,
+ string owner,
+ string repository,
+ string outputPath,
+ string lineEnding,
+ int currentYear,
CancellationToken cancellationToken = default)
{
Ensure.NotNull(serverUrl);
@@ -38,13 +64,18 @@ public static async Task GenerateAsync(
Ensure.NotNull(lineEnding);
string template = LicenseTemplate;
- int year = DateTime.UtcNow.Year;
// Build project URL
string projectUrl = $"{serverUrl}/{repository}";
- // Build copyright string
- string copyright = $"Copyright (c) 2023-{year} {owner} contributors";
+ // Keep an existing copyright line, and make one up only for a repository that has none
+ string copyrightPath = Path.Combine(outputPath, "COPYRIGHT.md");
+ string? existingCopyright = File.Exists(copyrightPath)
+ ? (await File.ReadAllTextAsync(copyrightPath, cancellationToken).ConfigureAwait(false)).Trim()
+ : null;
+ string copyright = string.IsNullOrEmpty(existingCopyright)
+ ? $"Copyright (c) 2023-{currentYear} {owner} contributors"
+ : existingCopyright!;
// Replace placeholders
string licenseContent = template
@@ -56,7 +87,6 @@ public static async Task GenerateAsync(
await LineEndingHelper.WriteFileAsync(licensePath, licenseContent, lineEnding, cancellationToken).ConfigureAwait(false);
// Write COPYRIGHT.md
- string copyrightPath = Path.Combine(outputPath, "COPYRIGHT.md");
await LineEndingHelper.WriteFileAsync(copyrightPath, copyright + lineEnding, lineEnding, cancellationToken).ConfigureAwait(false);
}