diff --git a/Source/Bake.Tests/ExplicitTests/GitHubReleaseCookTests.cs b/Source/Bake.Tests/ExplicitTests/GitHubReleaseCookTests.cs index 8c97bf40..2f6dbbca 100644 --- a/Source/Bake.Tests/ExplicitTests/GitHubReleaseCookTests.cs +++ b/Source/Bake.Tests/ExplicitTests/GitHubReleaseCookTests.cs @@ -25,7 +25,6 @@ using Bake.Services; using Bake.Tests.Helpers; using Bake.ValueObjects; -using Bake.ValueObjects.Artifacts; using Bake.ValueObjects.Recipes.GitHub; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -44,21 +43,14 @@ public async Task CreateRelease() { // Arrange var recipe = new GitHubReleaseRecipe( + string.Empty, new GitHubInformation( "rasmus", "testtest", new Uri("https://github.com/rasmus/testtest"), new Uri("https://api.guthub.com/")), - SemVer.Random, "a108d8a38b4ac154172cb7eeea8530e316ead798", - new ReleaseNotes(SemVer.Random, "This is a test"), - new Artifact[] - { - new ExecutableArtifact( - "test_linux", - Path.Combine(WorkingDirectory, "README.md"), - new Platform(ExecutableOperatingSystem.Linux, ExecutableArchitecture.Intel64)) - }); + []); // Arrange var result = await Sut.CookAsync( diff --git a/Source/Bake.Tests/Files/nuget-config.xml b/Source/Bake.Tests/Files/nuget-config.xml new file mode 100644 index 00000000..7e956965 --- /dev/null +++ b/Source/Bake.Tests/Files/nuget-config.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/Source/Bake.Tests/Helpers/BakeTest.cs b/Source/Bake.Tests/Helpers/BakeTest.cs index 19ef2648..a025a8a5 100644 --- a/Source/Bake.Tests/Helpers/BakeTest.cs +++ b/Source/Bake.Tests/Helpers/BakeTest.cs @@ -33,11 +33,8 @@ namespace Bake.Tests.Helpers { public abstract class BakeTest : TestProject { - private CancellationTokenSource? _timeout; - - private List _releases = null!; - protected IReadOnlyCollection Releases => _releases; - protected CancellationToken Timeout => _timeout!.Token; + private List _releases = null!; + protected IReadOnlyCollection Releases => _releases; protected BakeTest(string projectName) : base(projectName) { @@ -46,15 +43,7 @@ protected BakeTest(string projectName) : base(projectName) [SetUp] public void SetUpBakeTest() { - _timeout = new CancellationTokenSource(TimeSpan.FromMinutes(5)); - _releases = new List(); - } - - [TearDown] - public void TearDownBakeTest() - { - _timeout?.Dispose(); - _timeout = null; + _releases = new List(); } protected Task ExecuteAsync( @@ -107,20 +96,20 @@ private static void ReplaceService(IServiceCollection serviceCollection, T in private class TestGitHub : IGitHub { - private readonly List _releases; + private readonly List _releases; public TestGitHub( - List releases) + List releases) { _releases = releases; } public Task CreateReleaseAsync( - Release release, + GitHubRelease gitHubRelease, GitHubInformation gitHubInformation, CancellationToken cancellationToken) { - _releases.Add(release); + _releases.Add(gitHubRelease); return Task.CompletedTask; } diff --git a/Source/Bake.Tests/Helpers/TestFor.cs b/Source/Bake.Tests/Helpers/TestFor.cs index 8be8706c..a02cc136 100644 --- a/Source/Bake.Tests/Helpers/TestFor.cs +++ b/Source/Bake.Tests/Helpers/TestFor.cs @@ -32,10 +32,11 @@ namespace Bake.Tests.Helpers { public class TestFor : TestIt { + protected IServiceProvider ServiceProvider { get; private set; } = null!; + protected T Sut => _lazySut.Value; + private Lazy _lazySut = null!; - private ServiceProvider _serviceProvider = null!; private Logger _logger = null!; - protected T Sut => _lazySut.Value; [SetUp] public void SetUpTestFor() @@ -45,18 +46,18 @@ public void SetUpTestFor() .MinimumLevel.Verbose() .WriteTo.Sink(new LogSink(A())) .CreateLogger(); - _serviceProvider = Configure(new ServiceCollection()) + ServiceProvider = Configure(new ServiceCollection()) .AddLogging(b => b.AddSerilog(_logger)) .BuildServiceProvider(); - Inject(_serviceProvider.GetRequiredService>()); - Inject(_serviceProvider); + Inject(ServiceProvider.GetRequiredService>()); + Inject(ServiceProvider); } [TearDown] public void TearDownTestFor() { - _serviceProvider.Dispose(); + ((IDisposable)ServiceProvider).Dispose(); _logger.Dispose(); } diff --git a/Source/Bake.Tests/Helpers/TestIt.cs b/Source/Bake.Tests/Helpers/TestIt.cs index 26c62f96..bd20d3e6 100644 --- a/Source/Bake.Tests/Helpers/TestIt.cs +++ b/Source/Bake.Tests/Helpers/TestIt.cs @@ -31,13 +31,16 @@ namespace Bake.Tests.Helpers { public abstract class TestIt { + private CancellationTokenSource? _timeout; private List _filesToDelete = null!; protected IFixture Fixture { get; private set; } = null!; + protected CancellationToken Timeout => _timeout!.Token; [SetUp] public void SetUpTestIt() { + _timeout = new CancellationTokenSource(TimeSpan.FromMinutes(5)); _filesToDelete = new List(); Fixture = new Fixture().Customize(new AutoNSubstituteCustomization()); @@ -46,6 +49,9 @@ public void SetUpTestIt() [TearDown] public void TearDownTestIt() { + _timeout?.Dispose(); + _timeout = null; + foreach (var file in _filesToDelete) { if (File.Exists(file)) @@ -96,7 +102,7 @@ protected async Task ReadEmbeddedAsync( var resourceName = resourceNames.Single(n => n.EndsWith(fileEnding, StringComparison.OrdinalIgnoreCase)); await using var stream = assembly.GetManifestResourceStream(resourceName); using var streamReader = new StreamReader(stream!); - return await streamReader.ReadToEndAsync(); + return await streamReader.ReadToEndAsync(Timeout); } protected string Lines( @@ -105,12 +111,17 @@ protected string Lines( return string.Join(Environment.NewLine, lines); } + protected void DeleteAfter(string filePath) + { + _filesToDelete.Add(filePath); + } + protected async Task WriteEmbeddedAsync( string fileEnding) { var content = await ReadEmbeddedAsync(fileEnding); var path = Path.GetTempFileName(); - await System.IO.File.WriteAllTextAsync(path, content); + await File.WriteAllTextAsync(path, content, Timeout); _filesToDelete.Add(path); return path; } diff --git a/Source/Bake.Tests/Helpers/TestProject.cs b/Source/Bake.Tests/Helpers/TestProject.cs index 90e4a9cb..84fd6eed 100644 --- a/Source/Bake.Tests/Helpers/TestProject.cs +++ b/Source/Bake.Tests/Helpers/TestProject.cs @@ -46,7 +46,7 @@ protected TestProject( } [SetUp] - public void SetUpTestProject() + public async Task SetUpTestProject() { _folder = Folder.New; @@ -54,12 +54,19 @@ public void SetUpTestProject() { Sha = GitHelper.Create(_folder.Path); + var destination = Path.Join(_folder.Path, ProjectName); + DirectoryCopy( Path.Combine( ProjectHelper.GetRoot(), "TestProjects", ProjectName), - Path.Join(_folder.Path, ProjectName)); + destination); + + var nugetConfig = await ReadEmbeddedAsync("nuget-config.xml"); + await System.IO.File.WriteAllTextAsync( + Path.Combine(destination, "nuget.config"), + nugetConfig); } _previousCurrentDirectory = Directory.GetCurrentDirectory(); diff --git a/Source/Bake.Tests/Helpers/TestService.cs b/Source/Bake.Tests/Helpers/TestService.cs new file mode 100644 index 00000000..a9645100 --- /dev/null +++ b/Source/Bake.Tests/Helpers/TestService.cs @@ -0,0 +1,41 @@ +// MIT License +// +// Copyright (c) 2021-2025 Rasmus Mikkelsen +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +using Microsoft.Extensions.DependencyInjection; + +namespace Bake.Tests.Helpers +{ + public abstract class TestService : TestFor + where T : class + { + protected override T CreateSut() + { + return ServiceProvider.GetRequiredService(); + } + + protected override IServiceCollection Configure(IServiceCollection serviceCollection) + { + return base.Configure(serviceCollection) + .AddTransient(); + } + } +} diff --git a/Source/Bake.Tests/UnitTests/Cooking/Cooks/ReleaseCookTests.cs b/Source/Bake.Tests/UnitTests/Cooking/Cooks/ReleaseCookTests.cs new file mode 100644 index 00000000..d1e96cb4 --- /dev/null +++ b/Source/Bake.Tests/UnitTests/Cooking/Cooks/ReleaseCookTests.cs @@ -0,0 +1,198 @@ +// MIT License +// +// Copyright (c) 2021-2025 Rasmus Mikkelsen +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +using Bake.Cooking.Cooks.Release; +using Bake.Core; +using Bake.Tests.Helpers; +using Bake.ValueObjects.Recipes.Release; +using Bake.ValueObjects.Releases; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using NUnit.Framework; +using File = System.IO.File; + +namespace Bake.Tests.UnitTests.Cooking.Cooks +{ + public class ReleaseCookTests : TestService + { + [Test] + public async Task Empty() + { + // Arrange + var context = NewContext(); + + // Act + var success = await Sut.CookAsync( + context, + new ReleaseRecipe( + string.Empty, + []), + Timeout); + + // Assert + success.Should().BeTrue(); + } + + [Test] + public async Task Files() + { + // Arrange + var context = NewContext(); + + // Act + var success = await Sut.CookAsync( + context, + new ReleaseRecipe( + string.Empty, + [ + NewReleaseFile() + ]), + Timeout); + + // Assert + success.Should().BeTrue(); + } + + [Test] + public async Task Directories() + { + // Arrange + var context = NewContext(); + + // Act + var success = await Sut.CookAsync( + context, + new ReleaseRecipe( + string.Empty, + [ + NewReleaseDirectory() + ]), + Timeout); + + // Assert + success.Should().BeTrue(); + } + + [Test] + public async Task Mixed() + { + // Arrange + var context = NewContext(); + + // Act + var success = await Sut.CookAsync( + context, + new ReleaseRecipe( + string.Empty, + [ + NewMixedRelease(), + NewReleaseDirectory(), + NewReleaseFile(), + ]), + Timeout); + + // Assert + success.Should().BeTrue(); + } + + private static Context NewContext() + { + return Context.New(ValueObjects.Ingredients.New(SemVer.Random, Path.GetTempPath())); + } + + private ReleaseFile NewMixedRelease() + { + var fileName = $"{Guid.NewGuid():N}.zip"; + var destinationPath = Path.Combine(Path.GetTempPath(), fileName); + DeleteAfter(destinationPath); + + return new ReleaseFile( + fileName, + [NewDirectory(), NewFile(), NewFile(), NewDirectory()], + destinationPath); + } + + private ReleaseFile NewReleaseDirectory() + { + var fileName = $"{Guid.NewGuid():N}.zip"; + var destinationPath = Path.Combine(Path.GetTempPath(), fileName); + DeleteAfter(destinationPath); + + return new ReleaseFile( + fileName, + [NewDirectory()], + destinationPath); + } + + private ReleaseFile NewReleaseFile(int fileCount = 3) + { + var fileName = $"{Guid.NewGuid():N}.zip"; + var destinationPath = Path.Combine(Path.GetTempPath(), fileName); + DeleteAfter(destinationPath); + + return new ReleaseFile( + fileName, + Enumerable.Range(0, fileCount).Select(_ => NewFile()).ToArray(), + destinationPath); + } + + private string NewDirectory(params string[] path) + { + var name = Guid.NewGuid().ToString("N"); + path = path.Concat([name]).ToArray(); + var directory = path.Aggregate(Path.GetTempPath(), Path.Combine); + if (!Directory.Exists(directory)) + { + Directory.CreateDirectory(directory); + } + + _ = Enumerable.Range(0, 3).Select(_ => NewFile(directory)).ToArray(); + + return directory; + } + + private string NewFile(params string[] path) + { + var parentDirectory = path.Aggregate(Path.GetTempPath(), Path.Combine); + if (!Directory.Exists(parentDirectory)) + { + Directory.CreateDirectory(parentDirectory); + } + + var filePath = Path.Combine( + parentDirectory, + $"{Guid.NewGuid():N}.txt"); + + File.WriteAllText(filePath, "Hello there!"); + + DeleteAfter(filePath); + + return filePath; + } + + protected override IServiceCollection Configure(IServiceCollection serviceCollection) + { + return base.Configure(serviceCollection) + .AddTransient(); + } + } +} diff --git a/Source/Bake.Tests/UnitTests/Services/ComposerOrderingTests.cs b/Source/Bake.Tests/UnitTests/Services/ComposerOrderingTests.cs index d596257d..0fba6756 100644 --- a/Source/Bake.Tests/UnitTests/Services/ComposerOrderingTests.cs +++ b/Source/Bake.Tests/UnitTests/Services/ComposerOrderingTests.cs @@ -42,7 +42,7 @@ public void BasicOrdering() var composers = new[] { DummyProducer("E", ArtifactType.Executable), - Dummy("R", ArtifactType.Executable, ArtifactType.Release), + Dummy("R", ArtifactType.Executable, ArtifactType.GitHubRelease), DummyProducer("E", ArtifactType.Executable), }; @@ -94,12 +94,12 @@ private static IReadOnlyCollection GetNames(IEnumerable compo private static IComposer Dummy(string name, ArtifactType consume, ArtifactType produce) => new DummyComposer( name, - new[] { consume }, - new[] { produce }); + [consume], + [produce]); private class DummyComposer : IComposer { - private static readonly Task> EmptyRecipes = Task.FromResult>(new Recipe[] { }); + private static readonly Task> EmptyRecipes = Task.FromResult>([]); public string Name { get; } public IReadOnlyCollection Produces { get; } diff --git a/Source/Bake/Context.cs b/Source/Bake/Context.cs index 34ea0ad2..337df7e9 100644 --- a/Source/Bake/Context.cs +++ b/Source/Bake/Context.cs @@ -20,8 +20,6 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. -using System.Collections.Generic; -using System.Linq; using Bake.ValueObjects; using Bake.ValueObjects.Artifacts; diff --git a/Source/Bake/Cooking/Composers/GitHubReleaseComposer.cs b/Source/Bake/Cooking/Composers/GitHubReleaseComposer.cs index f82b8792..84761623 100644 --- a/Source/Bake/Cooking/Composers/GitHubReleaseComposer.cs +++ b/Source/Bake/Cooking/Composers/GitHubReleaseComposer.cs @@ -33,17 +33,8 @@ public class GitHubReleaseComposer : Composer { private readonly IConventionInterpreter _conventionInterpreter; - public override IReadOnlyCollection Consumes { get; } = new[] - { - ArtifactType.NuGet, - ArtifactType.Executable, - ArtifactType.DocumentationSite, - ArtifactType.Container - }; - public override IReadOnlyCollection Produces { get; } = new[] - { - ArtifactType.Release, - }; + public override IReadOnlyCollection Consumes { get; } = [ArtifactType.Release]; + public override IReadOnlyCollection Produces { get; } = [ArtifactType.GitHubRelease]; public GitHubReleaseComposer( IConventionInterpreter conventionInterpreter) @@ -69,32 +60,25 @@ public override Task> ComposeAsync( var gitHubDestination = context.Ingredients.Destinations .OfType() .SingleOrDefault(); - if (gitHubDestination == null) { return Task.FromResult(EmptyRecipes); } - var artifacts = Enumerable.Empty() - .Concat(context.GetArtifacts()) - .Concat(context.GetArtifacts()) - .Concat(context.GetArtifacts()) - .ToArray(); - - if (!artifacts.Any()) + var release = context.GetArtifacts().SingleOrDefault(); + if (release == null) { return Task.FromResult(EmptyRecipes); } - return Task.FromResult>(new[] - { - new GitHubReleaseRecipe( - context.Ingredients.GitHub, - context.Ingredients.Version, - context.Ingredients.Git.Sha, - context.Ingredients.ReleaseNotes!, - artifacts) - }); + return Task.FromResult>( + [ + new GitHubReleaseRecipe( + release.Text, + context.Ingredients.GitHub, + context.Ingredients.Git.Sha, + release.Files) + ]); } } } diff --git a/Source/Bake/Cooking/Composers/ReleaseComposer.cs b/Source/Bake/Cooking/Composers/ReleaseComposer.cs new file mode 100644 index 00000000..2ceb557d --- /dev/null +++ b/Source/Bake/Cooking/Composers/ReleaseComposer.cs @@ -0,0 +1,264 @@ +// MIT License +// +// Copyright (c) 2021-2025 Rasmus Mikkelsen +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +using System.Collections.Concurrent; +using System.Text; +using Bake.Core; +using Bake.ValueObjects; +using Bake.ValueObjects.Artifacts; +using Bake.ValueObjects.Recipes; +using Bake.ValueObjects.Recipes.Release; +using Bake.ValueObjects.Releases; +using Microsoft.Extensions.Logging; +using File = System.IO.File; + +namespace Bake.Cooking.Composers +{ + public class ReleaseComposer : Composer + { + private static readonly IReadOnlyDictionary NamingOs = new ConcurrentDictionary + { + [ExecutableOperatingSystem.Linux] = "linux", + [ExecutableOperatingSystem.MacOSX] = "macosx", + [ExecutableOperatingSystem.Windows] = "windows" + }; + private static readonly IReadOnlyDictionary NamingArch = new ConcurrentDictionary + { + [ExecutableArchitecture.Intel32] = "x86", + [ExecutableArchitecture.Intel64] = "x64", + [ExecutableArchitecture.Arm32] = "arm32", + [ExecutableArchitecture.Arm64] = "arm64", + }; + + public override IReadOnlyCollection Consumes { get; } = + [ + ArtifactType.Container, + ArtifactType.DocumentationSite, + ArtifactType.Executable, + ArtifactType.HelmChart, + ArtifactType.NuGet, + ]; + + public override IReadOnlyCollection Produces { get; } = [ArtifactType.Release]; + + private readonly ILogger _logger; + private readonly IDefaults _defaults; + + public ReleaseComposer( + ILogger logger, + IDefaults defaults) + { + _logger = logger; + _defaults = defaults; + } + + public override Task> ComposeAsync( + IContext context, + CancellationToken cancellationToken) + { + var artifacts = Enumerable.Empty() + .Concat(context.GetArtifacts()) + .Concat(context.GetArtifacts()) + .Concat(context.GetArtifacts()) + .Concat(context.GetArtifacts()) + .Concat(context.GetArtifacts()) + .ToArray(); + + if (!artifacts.Any()) + { + _logger.LogWarning("No artifacts found for release, skipping release creation!"); + return Task.FromResult(EmptyRecipes); + } + + var releaseTextBuilder = new StringBuilder(); + AddReleaseNotes(context, releaseTextBuilder); + AddChangeLog(context, releaseTextBuilder); + AddArtifactDescriptions(context, artifacts, releaseTextBuilder); + AddGitHubChangeLink(context, releaseTextBuilder); + + var releaseFiles = BuildReleaseFiles(context, artifacts); + var releaseText = releaseTextBuilder.ToString(); + + return Task.FromResult>( + [ + new ReleaseRecipe( + releaseText, + releaseFiles.ToArray(), + new ReleaseArtifact( + releaseText, + releaseFiles.Select(f => f.Destination).ToArray())) + ]); + } + + private List BuildReleaseFiles( + IContext context, + Artifact[] inputArtifacts) + { + var additionalSourceFiles = new[] + { + Path.Combine(context.Ingredients.WorkingDirectory, "README.md"), + Path.Combine(context.Ingredients.WorkingDirectory, "LICENSE"), + Path.Combine(context.Ingredients.WorkingDirectory, "RELEASE_NOTES.md"), + } + .Where(File.Exists) + .ToArray(); + + var releaseFiles = new List(); + + foreach (var g in inputArtifacts.GroupBy(a => a.GetType())) + { + switch (g.Key) + { + case { } t when t == typeof(DocumentationSiteArtifact): + { + foreach (var artifact in g) + { + var documentationSiteArtifact = (DocumentationSiteArtifact)artifact; + var fileName = $"documentation_v{context.Ingredients.Version}.zip"; + releaseFiles.Add(new ReleaseFile( + fileName, + AppendFiles(documentationSiteArtifact.Path), + Path.Combine(_defaults.BakeReleaseOutputDirectory, fileName))); + } + } + break; + + case { } t when t == typeof(ExecutableArtifact): + { + foreach (var artifact in g) + { + var executableArtifact = (ExecutableArtifact) artifact; + var fileName = CalculateArtifactFileName(executableArtifact); + releaseFiles.Add(new ReleaseFile( + fileName, + AppendFiles(executableArtifact.Path), + Path.Combine(_defaults.BakeReleaseOutputDirectory, fileName))); + } + } + break; + } + } + + return releaseFiles; + + string[] AppendFiles(params string[] paths) + { + return Enumerable.Empty() + .Concat(additionalSourceFiles) + .Concat(paths) + .OrderBy(p => p, StringComparer.OrdinalIgnoreCase) + .ToArray(); + } + } + + private static void AddReleaseNotes(IContext context, StringBuilder releaseText) + { + if (context.Ingredients.ReleaseNotes != null) + { + releaseText + .AppendLine("### Release notes") + .AppendLine(context.Ingredients.ReleaseNotes.Notes) + .AppendLine(); + } + } + + private static void AddArtifactDescriptions( + IContext _, + Artifact[] artifacts, + StringBuilder releaseText) + { + foreach (var g in artifacts.GroupBy(a => a.GetType())) + { + switch (g.Key) + { + case { } t when t == typeof(ContainerArtifact): + { + releaseText.AppendLine("### Containers"); + foreach (var artifact in g) + { + var containerArtifact = (ContainerArtifact) artifact; + if (containerArtifact.Name.StartsWith("bake.local", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + releaseText.AppendLine($"* `{containerArtifact.Name}`"); + foreach (var tag in containerArtifact.Tags) + { + releaseText.AppendLine($" * `{tag}`"); + } + } + } + break; + } + } + } + + private static void AddChangeLog(IContext context, StringBuilder releaseText) + { + if (context.Ingredients.Changelog == null || !context.Ingredients.Changelog.Changes.Any()) + { + return; + } + + foreach (var a in new[] + { + new {changeType = ChangeType.Other, title = "Changes"}, + new {changeType = ChangeType.Dependency, title = "Updated dependencies"}, + }) + { + releaseText + .AppendLine($"#### {a.title}") + .AppendLine(); + + foreach (var change in context.Ingredients.Changelog.Changes[a.changeType]) + { + releaseText.AppendLine($"* {change.Text}"); + } + releaseText.AppendLine(); + } + + releaseText.AppendLine(); + } + + private static void AddGitHubChangeLink(IContext context, StringBuilder releaseText) + { + if (context.Ingredients is {GitHub: not null, Changelog: not null}) + { + releaseText.AppendLine( + $"Full Changelog: {context.Ingredients.GitHub.Url.AbsoluteUri.TrimEnd('/')}/compare/{context.Ingredients.Changelog.PreviousReleaseTag.Sha}...{context.Ingredients.Git!.Sha}"); + } + } + + private static string CalculateArtifactFileName(ExecutableArtifact artifact) + { + var parts = new[] + { + artifact.Name, + NamingOs[artifact.Platform.Os], + NamingArch[artifact.Platform.Arch] + }; + + return $"{string.Join("_", parts)}.zip"; + } + } +} diff --git a/Source/Bake/Cooking/Cooks/.gitignore b/Source/Bake/Cooking/Cooks/.gitignore new file mode 100644 index 00000000..3a2535e1 --- /dev/null +++ b/Source/Bake/Cooking/Cooks/.gitignore @@ -0,0 +1 @@ +!Release diff --git a/Source/Bake/Cooking/Cooks/GitHub/GitHubReleaseCook.cs b/Source/Bake/Cooking/Cooks/GitHub/GitHubReleaseCook.cs index 1a264021..47acd64b 100644 --- a/Source/Bake/Cooking/Cooks/GitHub/GitHubReleaseCook.cs +++ b/Source/Bake/Cooking/Cooks/GitHub/GitHubReleaseCook.cs @@ -20,15 +20,10 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. -using System.Collections.Concurrent; -using System.IO.Compression; -using System.Text; using Bake.Core; using Bake.Services; using Bake.ValueObjects; -using Bake.ValueObjects.Artifacts; using Bake.ValueObjects.Recipes.GitHub; -using Microsoft.Extensions.Logging; // ReSharper disable StringLiteralTypo @@ -36,28 +31,13 @@ namespace Bake.Cooking.Cooks.GitHub { public class GitHubReleaseCook : Cook { - private static readonly IReadOnlyDictionary NamingOs = new ConcurrentDictionary - { - [ExecutableOperatingSystem.Linux] = "linux", - [ExecutableOperatingSystem.MacOSX] = "macosx", - [ExecutableOperatingSystem.Windows] = "windows" - }; - private static readonly IReadOnlyDictionary NamingArch = new ConcurrentDictionary - { - [ExecutableArchitecture.Intel32] = "x86", - [ExecutableArchitecture.Intel64] = "x86_64", - }; - - private readonly ILogger _logger; private readonly IGitHub _gitHub; private readonly IFileSystem _fileSystem; public GitHubReleaseCook( - ILogger logger, IGitHub gitHub, IFileSystem fileSystem) { - _logger = logger; _gitHub = gitHub; _fileSystem = fileSystem; } @@ -67,155 +47,24 @@ protected override async Task CookAsync( GitHubReleaseRecipe recipe, CancellationToken cancellationToken) { - var additionalFiles = new[] - { - Path.Combine(context.Ingredients.WorkingDirectory, "README.md"), - Path.Combine(context.Ingredients.WorkingDirectory, "LICENSE"), - Path.Combine(context.Ingredients.WorkingDirectory, "RELEASE_NOTES.md"), - } - .Where(System.IO.File.Exists) - .Select(p => _fileSystem.Open(p)) - .ToArray(); - - var stringBuilder = new StringBuilder(); - - if (recipe.ReleaseNotes != null) - { - stringBuilder - .AppendLine("### Release notes") - .AppendLine(recipe.ReleaseNotes.Notes) - .AppendLine(); - } - - if (context.Ingredients.Changelog != null && context.Ingredients.Changelog.Changes.Any()) - { - foreach (var a in new[] - { - new {changeType = ChangeType.Other, title = "Changes"}, - new {changeType = ChangeType.Dependency, title = "Updated dependencies"}, - }) - { - stringBuilder - .AppendLine($"#### {a.title}") - .AppendLine(); - - foreach (var change in context.Ingredients.Changelog.Changes[a.changeType]) - { - stringBuilder.AppendLine($"* {change.Text}"); - } - - stringBuilder.AppendLine(); - } - - stringBuilder.AppendLine(); - - if (context.Ingredients.GitHub != null) - { - stringBuilder.AppendLine( - $"Full Changelog: {context.Ingredients.GitHub.Url.AbsoluteUri.TrimEnd('/')}/compare/{context.Ingredients.Changelog.PreviousReleaseTag.Sha}...{context.Ingredients.Git!.Sha}"); - } - } - - var releaseFiles = (await CreateReleaseFilesAsync(additionalFiles, recipe, cancellationToken)).ToList(); - - var documentationSite = recipe.Artifacts - .OfType() - .FirstOrDefault(); - if (documentationSite != null) - { - _logger.LogInformation("Documentation site built, packing it into a release file"); - var documentationZipFilePath = Path.Combine( - Path.GetTempPath(), - Guid.NewGuid().ToString("N"), - "documentation.zip"); - Directory.CreateDirectory(Path.GetDirectoryName(documentationZipFilePath)!); - ZipFile.CreateFromDirectory(documentationSite.Path, documentationZipFilePath); - var file = _fileSystem.Open(documentationZipFilePath); - releaseFiles.Add(new ReleaseFile( - file, - $"documentation_v{context.Ingredients.Version}.zip", - await file.GetHashAsync(HashAlgorithm.SHA256, cancellationToken))); - } - - var containerArtifacts = recipe.Artifacts - .OfType() + var releaseFiles = recipe.Files + .Select(f => new GitHubReleaseFile( + _fileSystem.Get(f), + Path.GetFileName(f))) .ToArray(); - if (containerArtifacts.Any()) - { - stringBuilder.AppendLine("### Containers"); - foreach (var containerArtifact in containerArtifacts) - { - stringBuilder.AppendLine($"* `{containerArtifact.Name}`"); - foreach (var tag in containerArtifact.Tags) - { - stringBuilder.AppendLine($" * `{tag}`"); - } - } - } - - if (releaseFiles.Any()) - { - stringBuilder.AppendLine("### Files"); - foreach (var releaseFile in releaseFiles) - { - stringBuilder.AppendLine($"* `{releaseFile.Destination}`"); - stringBuilder.AppendLine($" * SHA256: `{releaseFile.Sha256}`"); - } - } - var release = new Release( - recipe.Version, + var gitHubRelease = new GitHubRelease( + context.Ingredients.Version, recipe.Sha, - stringBuilder.ToString(), + recipe.Text, releaseFiles); await _gitHub.CreateReleaseAsync( - release, + gitHubRelease, recipe.GitHubInformation, cancellationToken); return true; } - - private async Task> CreateReleaseFilesAsync( - IReadOnlyCollection additionalFiles, - GitHubReleaseRecipe recipe, - CancellationToken cancellationToken) - { - return await Task.WhenAll(recipe.Artifacts - .OfType() - .Select(async artifact => - { - var file = _fileSystem.Open(artifact.Path); - var fileName = CalculateArtifactFileName(artifact); - var compressedFile = await _fileSystem.CompressAsync( - fileName, - CompressionAlgorithm.ZIP, - Enumerable.Empty() - .Concat(additionalFiles) - .Concat(new[] {file,}) - .ToArray(), - cancellationToken); - var sha256 = await compressedFile.GetHashAsync( - HashAlgorithm.SHA256, - cancellationToken); - return new ReleaseFile( - compressedFile, - fileName, - sha256); - })); - } - - private static string CalculateArtifactFileName(ExecutableArtifact artifact) - { - var parts = new[] - { - artifact.Name, - NamingOs[artifact.Platform.Os], - NamingArch[artifact.Platform.Arch] - }; - - return $"{string.Join("_", parts)}.zip"; - } } } diff --git a/Source/Bake/Cooking/Cooks/OctopusDeploy/OctopusDeployPackagePushCook.cs b/Source/Bake/Cooking/Cooks/OctopusDeploy/OctopusDeployPackagePushCook.cs index b5ec1c0f..9926ca7b 100644 --- a/Source/Bake/Cooking/Cooks/OctopusDeploy/OctopusDeployPackagePushCook.cs +++ b/Source/Bake/Cooking/Cooks/OctopusDeploy/OctopusDeployPackagePushCook.cs @@ -86,7 +86,7 @@ private async Task UploadAsync( CancellationToken cancellationToken) { url = new Uri(url, "/api/packages/raw?replace=false"); - var file = _fileSystem.Open(packagePath); + var file = _fileSystem.Get(packagePath); await using var stream = await file.OpenReadAsync(cancellationToken); using var request = new HttpRequestMessage(HttpMethod.Post, url) { diff --git a/Source/Bake/Cooking/Cooks/Release/ReleaseCook.cs b/Source/Bake/Cooking/Cooks/Release/ReleaseCook.cs new file mode 100644 index 00000000..0df30d9a --- /dev/null +++ b/Source/Bake/Cooking/Cooks/Release/ReleaseCook.cs @@ -0,0 +1,101 @@ +// MIT License +// +// Copyright (c) 2021-2025 Rasmus Mikkelsen +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +using Bake.Core; +using Bake.ValueObjects.Recipes.Release; +using Bake.ValueObjects.Releases; +using Microsoft.Extensions.Logging; +using System.IO.Compression; +using Bake.Extensions; +using File = System.IO.File; + +namespace Bake.Cooking.Cooks.Release +{ + public class ReleaseCook : Cook + { + private readonly ILogger _logger; + private readonly IFileSystem _fileSystem; + + public ReleaseCook( + ILogger logger, + IFileSystem fileSystem) + { + _logger = logger; + _fileSystem = fileSystem; + } + + protected override async Task CookAsync( + IContext context, + ReleaseRecipe recipe, + CancellationToken cancellationToken) + { + foreach (var releaseFile in recipe.Files) + { + if (!await CompressReleaseFilesAsync(releaseFile, cancellationToken)) + { + return false; + } + } + + return true; + } + + private async Task CompressReleaseFilesAsync(ReleaseFile releaseFile, CancellationToken cancellationToken) + { + var tmpDirectory = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + _logger.LogInformation("Creating temporary directory at {TmpDirectory}", tmpDirectory); + foreach (var source in releaseFile.Sources) + { + if (File.Exists(source)) + { + var fileName = Path.GetFileName(source); + var destination = Path.Combine(tmpDirectory, fileName); + _logger.LogInformation("Copying file from {Source} to {Destination}", source, destination); + await _fileSystem.CopyFileAsync(source, destination, cancellationToken); + } + else if (Directory.Exists(source)) + { + _logger.LogInformation("Copying directory from {Source} to {Destination}", source, tmpDirectory); + await _fileSystem.CopyDirectoryAsync(source, tmpDirectory, cancellationToken); + } + else + { + _logger.LogError("The source {Source} does not exist", source); + return false; + } + } + + var destinationDirectory = Path.GetDirectoryName(releaseFile.Destination); + if (!string.IsNullOrEmpty(destinationDirectory) && !Directory.Exists(destinationDirectory)) + { + Directory.CreateDirectory(destinationDirectory); + } + + _logger.LogInformation("Creating ZIP file at {Destination}", releaseFile.Destination); + ZipFile.CreateFromDirectory(tmpDirectory, releaseFile.Destination); + var fileInfo = new FileInfo(releaseFile.Destination); + _logger.LogInformation("Created ZIP file {Destination} with size {Size}", releaseFile.Destination, fileInfo.Length.BytesToString()); + + return true; + } + } +} diff --git a/Source/Bake/Cooking/Ingredients/Gathers/DynamicDestinationGather.cs b/Source/Bake/Cooking/Ingredients/Gathers/DynamicDestinationGather.cs index cc8f0c0e..5eb6da44 100644 --- a/Source/Bake/Cooking/Ingredients/Gathers/DynamicDestinationGather.cs +++ b/Source/Bake/Cooking/Ingredients/Gathers/DynamicDestinationGather.cs @@ -77,7 +77,7 @@ public async Task GatherAsync( await ExtractContainerDestinationAsync(ingredients, dynamicDestination); break; - case ArtifactType.Release: + case ArtifactType.GitHubRelease: await ExtractReleaseDestinationAsync(ingredients, dynamicDestination); break; diff --git a/Source/Bake/Core/Defaults.cs b/Source/Bake/Core/Defaults.cs index 75744052..c9e2c2d6 100644 --- a/Source/Bake/Core/Defaults.cs +++ b/Source/Bake/Core/Defaults.cs @@ -43,6 +43,7 @@ public class Defaults : IDefaults public string DotNetRollForward { get; private set; } = "LatestMajor"; public TimeSpan BakeIngredientsGatherTimeout { get; private set; } = TimeSpan.FromMinutes(5); public TimeSpan BakeComposeTimeout { get; private set; } = TimeSpan.FromMinutes(5); + public string BakeReleaseOutputDirectory { get; private set; } = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"), "bake-release"); public Defaults( IEnvironmentVariables environmentVariables) @@ -70,6 +71,7 @@ public async Task InitializeAsync( DotNetRollForward = GetString(e, "dotnet_roll_forward", DotNetRollForward); BakeIngredientsGatherTimeout = TimeSpan.FromSeconds(GetDouble(e, "bake_ingredients_gather_timeout_seconds", BakeIngredientsGatherTimeout.TotalSeconds)); BakeComposeTimeout = TimeSpan.FromSeconds(GetDouble(e, "bake_compose_timeout_seconds", BakeComposeTimeout.TotalSeconds)); + BakeReleaseOutputDirectory = GetString(e, "bake_release_output_directory", BakeReleaseOutputDirectory); } private static bool GetBool( diff --git a/Source/Bake/Core/File.cs b/Source/Bake/Core/File.cs index 6cacbe62..93f3457e 100644 --- a/Source/Bake/Core/File.cs +++ b/Source/Bake/Core/File.cs @@ -20,17 +20,16 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. -using System; -using System.IO; +using System.Collections.Concurrent; using System.Security.Cryptography; -using System.Threading; -using System.Threading.Tasks; using HashAlgorithm = Bake.ValueObjects.HashAlgorithm; namespace Bake.Core { public class File : IFile { + private static readonly ConcurrentDictionary>> CachedHashes = new(); + public string Path { get; } public string FileName => System.IO.Path.GetFileName(Path); public long Size => new FileInfo(Path).Length; @@ -72,16 +71,24 @@ public async Task GetHashAsync( throw new ArgumentOutOfRangeException(nameof(hashAlgorithm)); } - await using var stream = await OpenReadAsync(cancellationToken); + return await CachedHashes.GetOrAdd( + $"{hashAlgorithm}:{Path}", + _ => new Lazy>( + async () => + { + await using var stream = await OpenReadAsync(cancellationToken); - using var sha256 = SHA256.Create(); - var checksum = await sha256.ComputeHashAsync(stream, cancellationToken); - return BitConverter.ToString(checksum).Replace("-", string.Empty); + using var sha256 = SHA256.Create(); + var checksum = await sha256.ComputeHashAsync(stream, cancellationToken); + return BitConverter.ToString(checksum).Replace("-", string.Empty); + }, + LazyThreadSafetyMode.ExecutionAndPublication)).Value; } public void Dispose() { System.IO.File.Delete(Path); + GC.SuppressFinalize(this); } } } diff --git a/Source/Bake/Core/FileSystem.cs b/Source/Bake/Core/FileSystem.cs index e6f05371..815ec5c8 100644 --- a/Source/Bake/Core/FileSystem.cs +++ b/Source/Bake/Core/FileSystem.cs @@ -85,6 +85,50 @@ public async Task> FindFilesAsync( return validPaths; } + public async Task CopyFileAsync( + string sourcePath, + string destinationPath, + CancellationToken cancellationToken) + { + var destinationParentDirectory = Path.GetDirectoryName(destinationPath); + if (string.IsNullOrEmpty(destinationParentDirectory)) + { + throw new ArgumentException($"Cannot determine parent directory of {destinationPath}"); + } + if (!Directory.Exists(destinationPath)) + { + Directory.CreateDirectory(destinationParentDirectory!); + } + + await using var sourceStream = new FileStream(sourcePath, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, true); + await using var destinationStream = new FileStream(destinationPath, FileMode.Create, FileAccess.Write, FileShare.None, 4096, true); + await sourceStream.CopyToAsync(destinationStream, 81920, cancellationToken); + } + + public async Task CopyDirectoryAsync( + string sourcePath, + string destinationPath, + CancellationToken cancellationToken) + { + var directoryInfo = new DirectoryInfo(sourcePath); + var directoryInfos = directoryInfo.GetDirectories(); + + Directory.CreateDirectory(destinationPath); + + var files = directoryInfo.GetFiles(); + foreach (var file in files) + { + var tempPath = Path.Combine(destinationPath, file.Name); + await CopyFileAsync(file.FullName, tempPath, cancellationToken); + } + + foreach (var subDirectory in directoryInfos) + { + var tempPath = Path.Combine(destinationPath, subDirectory.Name); + await CopyDirectoryAsync(subDirectory.FullName, tempPath, cancellationToken); + } + } + public async Task CompressAsync( string fileName, CompressionAlgorithm algorithm, @@ -96,7 +140,7 @@ public async Task CompressAsync( throw new ArgumentOutOfRangeException(nameof(algorithm)); } - if (!files.Any()) + if (files.Count == 0) { throw new ArgumentNullException(nameof(files)); } @@ -152,7 +196,7 @@ public async Task ReadAllTextAsync( return await streamReader.ReadToEndAsync(); } - public IFile Open(string filePath) + public IFile Get(string filePath) { return new File(filePath); } diff --git a/Source/Bake/Core/IDefaults.cs b/Source/Bake/Core/IDefaults.cs index f9d19024..ca77f518 100644 --- a/Source/Bake/Core/IDefaults.cs +++ b/Source/Bake/Core/IDefaults.cs @@ -45,6 +45,7 @@ public interface IDefaults TimeSpan BakeIngredientsGatherTimeout { get; } TimeSpan BakeComposeTimeout { get; } + string BakeReleaseOutputDirectory { get; } Task InitializeAsync( CancellationToken cancellationToken); diff --git a/Source/Bake/Core/IFileSystem.cs b/Source/Bake/Core/IFileSystem.cs index f7147f06..1036a102 100644 --- a/Source/Bake/Core/IFileSystem.cs +++ b/Source/Bake/Core/IFileSystem.cs @@ -40,7 +40,7 @@ Task ReadAllTextAsync( string filePath, CancellationToken cancellationToken); - IFile Open(string filePath); + IFile Get(string filePath); Task CompressAsync( string fileName, @@ -49,5 +49,15 @@ Task CompressAsync( CancellationToken cancellationToken); bool FileExists(string filePath); + + Task CopyFileAsync( + string sourcePath, + string destinationPath, + CancellationToken cancellationToken); + + Task CopyDirectoryAsync( + string sourcePath, + string destinationPath, + CancellationToken cancellationToken); } } diff --git a/Source/Bake/Extensions/LongExtensions.cs b/Source/Bake/Extensions/LongExtensions.cs index af03c568..f4ae9e09 100644 --- a/Source/Bake/Extensions/LongExtensions.cs +++ b/Source/Bake/Extensions/LongExtensions.cs @@ -20,7 +20,6 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. -using System; using System.Globalization; namespace Bake.Extensions diff --git a/Source/Bake/Extensions/ServiceCollectionExtensions.cs b/Source/Bake/Extensions/ServiceCollectionExtensions.cs index 2848fc31..f5292032 100644 --- a/Source/Bake/Extensions/ServiceCollectionExtensions.cs +++ b/Source/Bake/Extensions/ServiceCollectionExtensions.cs @@ -38,6 +38,7 @@ using Bake.Cooking.Cooks.OctopusDeploy; using Bake.Cooking.Cooks.Pip; using Bake.Cooking.Cooks.Python; +using Bake.Cooking.Cooks.Release; using Bake.Cooking.Ingredients.Gathers; using Bake.Core; using Bake.Services; @@ -112,6 +113,7 @@ public static IServiceCollection AddBake( .AddTransient() .AddTransient() .AddTransient() + .AddTransient() // Cooks - .NET .AddTransient() @@ -141,6 +143,8 @@ public static IServiceCollection AddBake( .AddTransient() // Cooks - GitHub .AddTransient() + // Cooks - Releases + .AddTransient() // NodeJS / NPM .AddTransient() .AddTransient() diff --git a/Source/Bake/Names.cs b/Source/Bake/Names.cs index 2f4ef53b..277021f5 100644 --- a/Source/Bake/Names.cs +++ b/Source/Bake/Names.cs @@ -60,6 +60,7 @@ public static class Artifacts public const string DirectoryArtifact = "directory-artifact"; public const string DocumentationSiteArtifact = "documentation-site-artifact"; public const string HelmChartArtifact = "helm-chart-artifact"; + public const string ReleaseArtifact = "release-artifact"; public static readonly IReadOnlyDictionary PluralNames = new ConcurrentDictionary { @@ -70,6 +71,7 @@ public static class Artifacts [NuGetArtifact] = "nuget packages", [DocumentationSiteArtifact] = "documentation sites", [HelmChartArtifact] = "helm charts", + [ReleaseArtifact] = "released files", }; } @@ -90,7 +92,7 @@ public static class ArtifactTypes [ArtifactType.Dockerfile] = Dockerfile, [ArtifactType.DotNetPublishedDirectory] = DotNetPublishedDirectory, [ArtifactType.NuGet] = NuGet, - [ArtifactType.Release] = Release, + [ArtifactType.GitHubRelease] = Release, [ArtifactType.Executable] = Executable, [ArtifactType.DocumentationSite] = DocumentationSite, }; @@ -134,7 +136,7 @@ public static class Python public static class GitHub { - public const string Release = "github-release"; + public const string GitHubRelease = "github-release"; } public static class Helm @@ -144,6 +146,11 @@ public static class Helm public const string DependenciesUpdate = "helm-dependencies-update"; } + public static class Releases + { + public const string Release = "release"; + } + public static class MkDocs { public const string Release = "mkdocs-build"; diff --git a/Source/Bake/Services/ComposerOrdering.cs b/Source/Bake/Services/ComposerOrdering.cs index b076d659..37f55dcc 100644 --- a/Source/Bake/Services/ComposerOrdering.cs +++ b/Source/Bake/Services/ComposerOrdering.cs @@ -20,9 +20,6 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. -using System; -using System.Collections.Generic; -using System.Linq; using Bake.Cooking; using Bake.Extensions; using Microsoft.Extensions.Logging; diff --git a/Source/Bake/Services/GitHub.cs b/Source/Bake/Services/GitHub.cs index fc45ce89..3eabd776 100644 --- a/Source/Bake/Services/GitHub.cs +++ b/Source/Bake/Services/GitHub.cs @@ -30,7 +30,6 @@ using Author = Bake.ValueObjects.Author; using Commit = Bake.ValueObjects.Commit; using PullRequest = Bake.ValueObjects.PullRequest; -using Release = Bake.ValueObjects.Release; namespace Bake.Services { @@ -57,7 +56,7 @@ public GitHub( } public async Task CreateReleaseAsync( - Release release, + GitHubRelease gitHubRelease, GitHubInformation gitHubInformation, CancellationToken cancellationToken) { @@ -68,33 +67,33 @@ public async Task CreateReleaseAsync( "Could not create a GitHub release due to missing credentials"); } - var tag = $"v{release.Version}"; + var tag = $"v{gitHubRelease.Version}"; - var gitHubRelease = await gitHubClient.Repository.Release.Create( + var octoKitRelease = await gitHubClient.Repository.Release.Create( gitHubInformation.Owner, gitHubInformation.Repository, new NewRelease(tag) { - Prerelease = release.Version.IsPrerelease, - TargetCommitish = release.Sha, - Body = release.Body, + Prerelease = gitHubRelease.Version.IsPrerelease, + TargetCommitish = gitHubRelease.Sha, + Body = gitHubRelease.Body, Draft = true, - Name = $"v{release.Version}", + Name = $"v{gitHubRelease.Version}", }); - if (release.Files.Count != 0) + if (gitHubRelease.Files.Count != 0) { - var uploadTasks = release.Files - .Select(f => UploadFileAsync(f, gitHubRelease, gitHubClient, cancellationToken)); + var uploadTasks = gitHubRelease.Files + .Select(f => UploadFileAsync(f, octoKitRelease, gitHubClient, cancellationToken)); await Task.WhenAll(uploadTasks); } - var gitHubReleaseUpdate = gitHubRelease.ToUpdate(); + var gitHubReleaseUpdate = octoKitRelease.ToUpdate(); gitHubReleaseUpdate.Draft = false; await gitHubClient.Repository.Release.Edit( gitHubInformation.Owner, gitHubInformation.Repository, - gitHubRelease.Id, + octoKitRelease.Id, gitHubReleaseUpdate); } @@ -316,18 +315,18 @@ public async Task> GetPullRequestsAsync( } private async Task UploadFileAsync( - ReleaseFile releaseFile, - Octokit.Release gitHubRelease, + GitHubReleaseFile gitHubReleaseFile, + Release gitHubRelease, IGitHubClient gitHubClient, CancellationToken cancellationToken) { var stopwatch = Stopwatch.StartNew(); _logger.LogDebug( "Uploading releaseFile {FileName} to GitHub release {ReleaseUrl}", - releaseFile.Source.FileName, + gitHubReleaseFile.Source.FileName, gitHubRelease.Url); - await using var stream = await releaseFile.Source.OpenReadAsync(cancellationToken); + await using var stream = await gitHubReleaseFile.Source.OpenReadAsync(cancellationToken); try { @@ -336,7 +335,7 @@ await gitHubClient.Repository.Release.UploadAsset( new ReleaseAssetUpload { ContentType = "application/octet-stream", - FileName = releaseFile.Destination, + FileName = gitHubReleaseFile.ReleaseFileName, RawData = stream, }, cancellationToken); @@ -349,7 +348,7 @@ await gitHubClient.Repository.Release.UploadAsset( _logger.LogInformation( "Done uploading releaseFile {FileName} to GitHub release {ReleaseUrl} after {TotalSeconds} seconds", - releaseFile.Source.FileName, + gitHubReleaseFile.Source.FileName, gitHubRelease.Url, stopwatch.Elapsed.TotalSeconds); } diff --git a/Source/Bake/Services/IGitHub.cs b/Source/Bake/Services/IGitHub.cs index 4ed15b50..ee8a59d4 100644 --- a/Source/Bake/Services/IGitHub.cs +++ b/Source/Bake/Services/IGitHub.cs @@ -27,7 +27,7 @@ namespace Bake.Services public interface IGitHub { Task CreateReleaseAsync( - Release release, + GitHubRelease gitHubRelease, GitHubInformation gitHubInformation, CancellationToken cancellationToken); diff --git a/Source/Bake/Services/Uploader.cs b/Source/Bake/Services/Uploader.cs index 540c4922..970fa4ca 100644 --- a/Source/Bake/Services/Uploader.cs +++ b/Source/Bake/Services/Uploader.cs @@ -67,7 +67,7 @@ public async Task UploadAsync( Uri url, CancellationToken cancellationToken) { - var file = _fileSystem.Open(filePath); + var file = _fileSystem.Get(filePath); var fileName = Path.GetFileName(filePath); var fileExtension = Path.GetExtension(filePath).Trim('.'); var mediaType = MediaTypes.TryGetValue(fileExtension, out var t) ? t : DefaultMediaType; diff --git a/Source/Bake/ValueObjects/.gitignore b/Source/Bake/ValueObjects/.gitignore index 6a890271..5f6ad16e 100644 --- a/Source/Bake/ValueObjects/.gitignore +++ b/Source/Bake/ValueObjects/.gitignore @@ -1 +1,3 @@ !Artifacts +!Release +!Releases diff --git a/Source/Bake/ValueObjects/Artifacts/Artifact.cs b/Source/Bake/ValueObjects/Artifacts/Artifact.cs index 2b80a2bb..1a8fa4e8 100644 --- a/Source/Bake/ValueObjects/Artifacts/Artifact.cs +++ b/Source/Bake/ValueObjects/Artifacts/Artifact.cs @@ -24,7 +24,7 @@ namespace Bake.ValueObjects.Artifacts { public abstract class Artifact : ValueObject { - public static IReadOnlyCollection Empty { get; } = new Artifact[] { }; + public static IReadOnlyCollection Empty { get; } = []; public abstract IAsyncEnumerable ValidateAsync( CancellationToken cancellationToken); diff --git a/Source/Bake/ValueObjects/Artifacts/ArtifactAttribute.cs b/Source/Bake/ValueObjects/Artifacts/ArtifactAttribute.cs index 906d617c..d21318a2 100644 --- a/Source/Bake/ValueObjects/Artifacts/ArtifactAttribute.cs +++ b/Source/Bake/ValueObjects/Artifacts/ArtifactAttribute.cs @@ -20,8 +20,6 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. -using System; - namespace Bake.ValueObjects.Artifacts { public class ArtifactAttribute : Attribute, IYamlTag diff --git a/Source/Bake/ValueObjects/Artifacts/ArtifactType.cs b/Source/Bake/ValueObjects/Artifacts/ArtifactType.cs index 20bb7728..88d23789 100644 --- a/Source/Bake/ValueObjects/Artifacts/ArtifactType.cs +++ b/Source/Bake/ValueObjects/Artifacts/ArtifactType.cs @@ -33,6 +33,7 @@ public enum ArtifactType HelmChart, Container, DocumentationSite, + GitHubRelease, Release, } } diff --git a/Source/Bake/ValueObjects/Artifacts/ReleaseArtifact.cs b/Source/Bake/ValueObjects/Artifacts/ReleaseArtifact.cs new file mode 100644 index 00000000..df061ab0 --- /dev/null +++ b/Source/Bake/ValueObjects/Artifacts/ReleaseArtifact.cs @@ -0,0 +1,64 @@ +// MIT License +// +// Copyright (c) 2021-2025 Rasmus Mikkelsen +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +using YamlDotNet.Serialization; + +namespace Bake.ValueObjects.Artifacts +{ + [Artifact(Names.Artifacts.ReleaseArtifact)] + public class ReleaseArtifact : Artifact + { + [YamlMember] + public string Text { get; [Obsolete] set; } = null!; + + [YamlMember] + public string[] Files { get; [Obsolete] set; } = null!; + + [Obsolete] + public ReleaseArtifact() { } + + public ReleaseArtifact( + string text, + string[] files) + { +#pragma warning disable CS0612 // Type or member is obsolete + Text = text; + Files = files; +#pragma warning restore CS0612 // Type or member is obsolete + } + + public override IAsyncEnumerable ValidateAsync(CancellationToken cancellationToken) + { + var missingFiles = Files + .Where(file => !File.Exists(file)) + .Select(f => $"File '{f}' is missing!") + .ToArray(); + + return missingFiles.ToAsyncEnumerable(); + } + + public override IEnumerable PrettyNames() + { + return Files; + } + } +} diff --git a/Source/Bake/ValueObjects/Release.cs b/Source/Bake/ValueObjects/GitHubRelease.cs similarity index 87% rename from Source/Bake/ValueObjects/Release.cs rename to Source/Bake/ValueObjects/GitHubRelease.cs index cb2e37f4..598f438a 100644 --- a/Source/Bake/ValueObjects/Release.cs +++ b/Source/Bake/ValueObjects/GitHubRelease.cs @@ -20,21 +20,20 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. -using System.Collections.Generic; using Bake.Core; namespace Bake.ValueObjects { - public class Release : Tag + public class GitHubRelease : Tag { public string Body { get; } - public IReadOnlyCollection Files { get; } + public IReadOnlyCollection Files { get; } - public Release( + public GitHubRelease( SemVer version, string sha, string body, - IReadOnlyCollection files) + IReadOnlyCollection files) : base(version, sha) { Body = body; diff --git a/Source/Bake/ValueObjects/ReleaseFile.cs b/Source/Bake/ValueObjects/GitHubReleaseFile.cs similarity index 84% rename from Source/Bake/ValueObjects/ReleaseFile.cs rename to Source/Bake/ValueObjects/GitHubReleaseFile.cs index 82021540..ac625532 100644 --- a/Source/Bake/ValueObjects/ReleaseFile.cs +++ b/Source/Bake/ValueObjects/GitHubReleaseFile.cs @@ -24,19 +24,16 @@ namespace Bake.ValueObjects; -public class ReleaseFile +public class GitHubReleaseFile { public IFile Source { get; } - public string Destination { get; } - public string Sha256 { get; } + public string ReleaseFileName { get; } - public ReleaseFile( + public GitHubReleaseFile( IFile source, - string destination, - string sha256) + string releaseFileName) { Source = source; - Destination = destination; - Sha256 = sha256; + ReleaseFileName = releaseFileName; } } diff --git a/Source/Bake/ValueObjects/Recipes/GitHub/GitHubReleaseRecipe.cs b/Source/Bake/ValueObjects/Recipes/GitHub/GitHubReleaseRecipe.cs index 58796c40..8362a79f 100644 --- a/Source/Bake/ValueObjects/Recipes/GitHub/GitHubReleaseRecipe.cs +++ b/Source/Bake/ValueObjects/Recipes/GitHub/GitHubReleaseRecipe.cs @@ -20,44 +20,39 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. -using Bake.Core; -using Bake.ValueObjects.Artifacts; using YamlDotNet.Serialization; namespace Bake.ValueObjects.Recipes.GitHub { - [Recipe(Names.Recipes.GitHub.Release)] + [Recipe(Names.Recipes.GitHub.GitHubRelease)] public class GitHubReleaseRecipe : Recipe { [YamlMember] - public GitHubInformation GitHubInformation { get; [Obsolete] set; } = null!; + public string Text { get; [Obsolete] set; } = null!; [YamlMember] - public SemVer Version { get; [Obsolete] set; } = null!; + public GitHubInformation GitHubInformation { get; [Obsolete] set; } = null!; [YamlMember] public string Sha { get; [Obsolete] set; } = null!; [YamlMember] - public ReleaseNotes? ReleaseNotes { get; [Obsolete] set; } + public string[] Files { get; [Obsolete] set; } = null!; [Obsolete] public GitHubReleaseRecipe() { } public GitHubReleaseRecipe( + string text, GitHubInformation gitHubInformation, - SemVer version, string sha, - ReleaseNotes? releaseNotes, - Artifact[] artifacts) - : base(artifacts) + string[] files) { #pragma warning disable CS0612 // Type or member is obsolete + Text = text; GitHubInformation = gitHubInformation; - Version = version; Sha = sha; - ReleaseNotes = releaseNotes; - Artifacts = artifacts; + Files = files; #pragma warning restore CS0612 // Type or member is obsolete } } diff --git a/Source/Bake/ValueObjects/Recipes/Release/ReleaseRecipe.cs b/Source/Bake/ValueObjects/Recipes/Release/ReleaseRecipe.cs new file mode 100644 index 00000000..78acdd1f --- /dev/null +++ b/Source/Bake/ValueObjects/Recipes/Release/ReleaseRecipe.cs @@ -0,0 +1,54 @@ +// MIT License +// +// Copyright (c) 2021-2025 Rasmus Mikkelsen +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +using Bake.ValueObjects.Artifacts; +using Bake.ValueObjects.Releases; +using YamlDotNet.Serialization; + +namespace Bake.ValueObjects.Recipes.Release +{ + [Recipe(Names.Recipes.Releases.Release)] + public class ReleaseRecipe : Recipe + { + [YamlMember] + public string Text { get; [Obsolete] set; } = null!; + + [YamlMember] + public ReleaseFile[] Files { get; [Obsolete] set; } = null!; + + [Obsolete] + public ReleaseRecipe() { } + + public ReleaseRecipe( + string text, + ReleaseFile[] files, + params Artifact[] artifacts) + : base(artifacts) + { +#pragma warning disable CS0612 // Type or member is obsolete + Text = text; + Files = files; + Artifacts = artifacts; +#pragma warning restore CS0612 // Type or member is obsolete + } + } +} diff --git a/Source/Bake/ValueObjects/Releases/ReleaseFile.cs b/Source/Bake/ValueObjects/Releases/ReleaseFile.cs new file mode 100644 index 00000000..98e679ca --- /dev/null +++ b/Source/Bake/ValueObjects/Releases/ReleaseFile.cs @@ -0,0 +1,53 @@ +// MIT License +// +// Copyright (c) 2021-2025 Rasmus Mikkelsen +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +using YamlDotNet.Serialization; + +namespace Bake.ValueObjects.Releases +{ + public class ReleaseFile + { + [YamlMember] + public string Name { get; [Obsolete] set; } = null!; + + [YamlMember] + public string[] Sources { get; [Obsolete] set; } = null!; + + [YamlMember] + public string Destination { get; [Obsolete] set; } = null!; + + [Obsolete] + public ReleaseFile() { } + + public ReleaseFile( + string name, + string[] sources, + string destination) + { +#pragma warning disable CS0612 // Type or member is obsolete + Name = name; + Sources = sources; + Destination = destination; +#pragma warning restore CS0612 // Type or member is obsolete + } + } +}