diff --git a/.github/actions/cake/action.yml b/.github/actions/cake/action.yml new file mode 100644 index 0000000..9eb85c0 --- /dev/null +++ b/.github/actions/cake/action.yml @@ -0,0 +1,37 @@ +name: Run a Cake build target +description: Restore the pinned build tool and run the same compiled target used locally. +inputs: + target: + description: Cake target + required: true + suite: + description: Optional named offline suite + default: '' + framework: + description: Optional framework selector, validated against the suite + default: '' + output: + description: New per-job output directory + default: artifacts/cake-pilot/ci +runs: + using: composite + steps: + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + - name: Restore the pinned Cake tool + shell: pwsh + run: dotnet restore build/Build.csproj --locked-mode + - name: Run Cake + shell: pwsh + env: + CAKE_TARGET: ${{ inputs.target }} + CAKE_SUITE: ${{ inputs.suite }} + CAKE_FRAMEWORK: ${{ inputs.framework }} + CAKE_OUTPUT: ${{ inputs.output }} + run: | + $cakeArguments = @('run', '--project', 'build/Build.csproj', '-c', 'Release', '--no-restore', '--', "--target=$env:CAKE_TARGET", "--output=$env:CAKE_OUTPUT") + if ($env:CAKE_SUITE) { $cakeArguments += "--suite=$env:CAKE_SUITE" } + if ($env:CAKE_FRAMEWORK) { $cakeArguments += "--framework=$env:CAKE_FRAMEWORK" } + & dotnet @cakeArguments + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } diff --git a/.github/workflows/cake-pilot.yml b/.github/workflows/cake-pilot.yml new file mode 100644 index 0000000..990ce82 --- /dev/null +++ b/.github/workflows/cake-pilot.yml @@ -0,0 +1,35 @@ +name: Cake Frosting pilot + +on: + workflow_dispatch: + +permissions: + contents: read + +jobs: + offline-pilot: + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + - name: Restore the pinned build tool + run: dotnet restore build/Build.csproj --locked-mode + - name: Test artifact comparison safeguards + run: dotnet test build/Tests/Build.Tests.csproj -c Release --logger "trx;LogFileName=build-tests.trx" --results-directory artifacts/cake-build-tests + - name: Build, test, package and verify offline + run: dotnet run --project build/Build.csproj -c Release --no-restore -- --target=Verify --output=artifacts/cake-pilot/ci + - name: Retain pilot results and packages + if: always() + uses: actions/upload-artifact@v4 + with: + name: cake-pilot-${{ github.run_id }}-${{ github.run_attempt }} + path: | + artifacts/cake-pilot/ci/*.json + artifacts/cake-pilot/ci/*.log + artifacts/cake-pilot/ci/test-results/*.trx + artifacts/cake-pilot/ci/nuget/*.nupkg + artifacts/cake-pilot/ci/release/* + artifacts/cake-build-tests/*.trx + if-no-files-found: warn diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index fa11425..a946842 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -20,18 +20,19 @@ jobs: runs-on: windows-latest steps: - uses: actions/checkout@v4 - - uses: actions/setup-dotnet@v4 - with: - dotnet-version: 10.0.x - name: Test private credential storage and authorization callbacks without live credentials - run: dotnet test TeslaPowerwallLibrary.TestCredentials.Tests/TeslaPowerwallLibrary.TestCredentials.Tests.csproj -c Release --logger "trx;LogFileName=credentials.trx" --results-directory TestResults - - name: Build the Windows setup application - run: dotnet build TeslaPowerwallLibrary.Setup/TeslaPowerwallLibrary.Setup.csproj -c Release + uses: ./.github/actions/cake + with: + target: CredentialTools + suite: credentials - uses: actions/upload-artifact@v4 if: always() with: name: credential-tool-tests - path: TestResults/*.trx + path: | + artifacts/cake-pilot/ci/test-results/*.trx + artifacts/cake-pilot/ci/*.json + artifacts/cake-pilot/ci/*.log if-no-files-found: warn test: runs-on: windows-latest @@ -44,18 +45,21 @@ jobs: project: TeslaPowerwallLibrary.Tests/TeslaPowerwallLibrary.Tests.csproj steps: - uses: actions/checkout@v4 - - uses: actions/setup-dotnet@v4 - with: - dotnet-version: 10.0.x - name: Run unit tests - shell: pwsh - run: dotnet test "${{ matrix.suite.project }}" -c Release -f "${{ matrix.framework }}" --filter "TestCategory!=Live" --logger "trx;LogFileName=tests.trx" --results-directory TestResults + uses: ./.github/actions/cake + with: + target: Suite + suite: ${{ matrix.suite.name }} + framework: ${{ matrix.framework }} - name: Retain unit-test results if: always() uses: actions/upload-artifact@v4 with: name: unit-tests-${{ matrix.suite.name }}-${{ matrix.framework }} - path: TestResults/*.trx + path: | + artifacts/cake-pilot/ci/test-results/*.trx + artifacts/cake-pilot/ci/*.json + artifacts/cake-pilot/ci/*.log if-no-files-found: warn companion-tests: @@ -75,23 +79,55 @@ jobs: framework: net10.0-windows steps: - uses: actions/checkout@v4 - - uses: actions/setup-dotnet@v4 - with: - dotnet-version: 10.0.x - name: Test companion application offline - run: dotnet test "${{ matrix.project }}" -c Release -f "${{ matrix.framework }}" -p:GenerateDocfxDocumentation=false --logger "trx;LogFileName=tests.trx" --results-directory TestResults + uses: ./.github/actions/cake + with: + target: Suite + suite: ${{ matrix.suite }} + framework: ${{ matrix.framework }} - uses: actions/upload-artifact@v4 if: always() with: name: tests-${{ matrix.suite }} - path: TestResults/*.trx + path: | + artifacts/cake-pilot/ci/test-results/*.trx + artifacts/cake-pilot/ci/*.json + artifacts/cake-pilot/ci/*.log package-compatibility: + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + - name: Pack and compare public API with the released baseline + uses: ./.github/actions/cake + with: + target: ReleaseArtifacts + - name: Retain verified local packages and release assets + if: always() + uses: actions/upload-artifact@v4 + with: + name: package-compatibility + path: | + artifacts/cake-pilot/ci/nuget/*.nupkg + artifacts/cake-pilot/ci/release/* + artifacts/cake-pilot/ci/*.json + artifacts/cake-pilot/ci/*.log + if-no-files-found: warn + + build-tool-tests: runs-on: windows-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-dotnet@v4 with: dotnet-version: 10.0.x - - name: Pack and compare public API with the released baseline - run: dotnet pack TeslaPowerwallLibrary/TeslaPowerwallLibrary.csproj -c Release -p:GenerateDocfxDocumentation=false --output artifacts/nuget + - name: Restore the pinned build tool + run: dotnet restore build/Build.csproj --locked-mode + - name: Test artifact comparison and CI task safeguards + run: dotnet test build/Tests/Build.Tests.csproj -c Release --logger "trx;LogFileName=build-tests.trx" --results-directory artifacts/cake-build-tests + - uses: actions/upload-artifact@v4 + if: always() + with: + name: build-tool-tests + path: artifacts/cake-build-tests/*.trx + if-no-files-found: warn diff --git a/DEVELOPMENT-HISTORY.md b/DEVELOPMENT-HISTORY.md index 21ac565..d57163f 100644 --- a/DEVELOPMENT-HISTORY.md +++ b/DEVELOPMENT-HISTORY.md @@ -1,5 +1,18 @@ # Development and validation history +## Cake CI integration — 31 October 2026 + +- The existing `Unit tests` workflow delegates its original six offline desktop runs to named Cake targets, retaining its job IDs and matrix labels. The credential job retains the setup-application build. Package compatibility now also exercises the five application bundles through an artifact-only target, and a separate job checks build-tool safeguards. +- A local composite action supplies the .NET SDK, locked Cake restore and argument-safe invocation. GitHub still owns triggers, runners, parallelism, permissions and artifacts. The public release workflow, shared validation policy, processor checks and publication locks remain unchanged; the artifact-only Cake target runs no desktop tests and publishes nothing. +- Local CI-selector, zero-test and dependency-graph safeguards passed 19 cases. Hosted validation is recorded below after execution. + +## Cake Frosting build pilot — 31 October 2026 + +- Added an optional compiled build project under `build/`, with pinned Cake.Frosting 6.3.0, for the existing solution, six offline desktop test runs, NuGet package/API validation and five application bundles. Runtime project dependencies, versions and the public release workflow are unchanged. +- The manual-only pilot workflow invokes the same entry point as a local build. It retains command logs, test reports and artifact inventories. Processor reservations, release-validation reuse, publication locking and external publication remain in their existing workflows and tools. +- Optional reference comparison checks every release file and uncompressed archive payload while accounting for ZIP container metadata and generated NuGet OPC identifiers. Ten comparison/version safeguards passed. The final Cake run passed all 1,309 offline cases, SDK package/API validation and five application bundles; all 228 manifest payload entries matched local reference assets generated with the existing release commands after a solution clean. The successful warm-build pipeline took 52.5 seconds on .NET SDK 10.0.403. Evidence is retained under `artifacts/cake-pilot/verified-20261031/` and `artifacts/cake-reference-20261031-clean/`; no hosted CI, live hardware or processor run is claimed. +- Initial comparisons detected differing application dependency metadata. Matching the workflow's unversioned restore removed one cause; an exact publish-command probe and a clean reference build identified cached files in the original warm reference. Earlier failed comparisons remain preserved. No dependency-file differences were normalized away. See `build/README.md` for commands and the comparison limits. + ## 2.3.2 release preparation — 30 October 2026 - Implemented directly in the maintained `C:\Projects\TeslaPowerwallLibrary` checkout. Matched the bounded solar/load correction in pypowerwall commit `6133761da20784fb0fe390c00525cef8fb1c6e92`: exact alert, missing/0–10 W controller solar, positive scaled remote solar total and timezone-aware samples within ±60 seconds of gateway time. There is no new last-good-value cache. Complete enabled CTs and unique meters are required; mixed meter families cannot supply a partial solar total. diff --git a/README.md b/README.md index 372e9f2..c1147d3 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ For shipped changes, see the [changelog](CHANGELOG.md). Test, CI and build history is recorded separately in [development and validation history](DEVELOPMENT-HISTORY.md). +An optional [Cake Frosting pilot](build/README.md) runs the offline build, tests and packaging locally or through a manual CI workflow. + A .NET client library for the Tesla™ Powerwall™ Energy Gateway, providing classic gateway and TEDAPI local access alongside Tesla Owner and Fleet cloud APIs to status, power flow, energy history, and control operations. diff --git a/build/ArtifactVerification.cs b/build/ArtifactVerification.cs new file mode 100644 index 0000000..637e44d --- /dev/null +++ b/build/ArtifactVerification.cs @@ -0,0 +1,85 @@ +// Copyright (c) 2026 Neil Colvin. MIT License; see ../TeslaPowerwallLibrary/LICENSE. +using System.IO.Compression; +using System.Security.Cryptography; +using System.Text; +using System.Xml.Linq; + +public static class ArtifactVerification +{ + private static string Hash(byte[] bytes) => Convert.ToHexStringLower(SHA256.HashData(bytes)); + public static SortedDictionary Inventory(string root) + { + var result = new SortedDictionary(StringComparer.Ordinal); + foreach (string folder in new[] { "nuget", "release" }) + { + string directory = Path.Combine(root, folder); + if (!Directory.Exists(directory)) throw new DirectoryNotFoundException(directory); + var files = Directory.GetFiles(directory); + if (files.Length == 0) throw new InvalidDataException("Empty artifact directory: " + directory); + foreach (string file in files.Order(StringComparer.Ordinal)) + { + string key = folder + "/" + Path.GetFileName(file); + if (file.EndsWith(".zip", StringComparison.OrdinalIgnoreCase) || file.EndsWith(".nupkg", StringComparison.OrdinalIgnoreCase)) + foreach (var entry in Archive(file)) result.Add(key + "!/" + entry.Key, entry.Value); + else result.Add(key, Hash(File.ReadAllBytes(file))); + } + } + return result; + } + public static SortedDictionary Archive(string path) + { + using var archive = ZipFile.OpenRead(path); + var result = new SortedDictionary(StringComparer.Ordinal); + var metadata = archive.Entries.Where(e => e.FullName.StartsWith("package/services/metadata/core-properties/", StringComparison.Ordinal) && e.FullName.EndsWith(".psmdcp", StringComparison.Ordinal)).ToArray(); + bool nuget = path.EndsWith(".nupkg", StringComparison.OrdinalIgnoreCase); + if (nuget && metadata.Length != 1) throw new InvalidDataException("Expected one NuGet core-properties part."); + foreach (var entry in archive.Entries.Where(e => e.Name != "")) + { + string name = entry.FullName.Replace('\\', '/'); + using var input = entry.Open(); using var buffer = new MemoryStream(); input.CopyTo(buffer); + byte[] bytes = buffer.ToArray(); + // NuGet generates OPC relationship IDs and sometimes the metadata filename. + // Retain every relationship's type/target and every core-properties byte. + if (nuget && name == "_rels/.rels") + { + var xml = XDocument.Parse(Encoding.UTF8.GetString(bytes)); + foreach (var relation in xml.Root!.Elements()) + { + relation.Attribute("Id")?.Remove(); + var target = relation.Attribute("Target"); + if (target?.Value.TrimStart('/') == metadata[0].FullName) target.Value = "/package/services/metadata/core-properties/nuget.psmdcp"; + } + bytes = Encoding.UTF8.GetBytes(xml.ToString(SaveOptions.DisableFormatting)); + } + if (nuget && name == metadata[0].FullName) name = "package/services/metadata/core-properties/nuget.psmdcp"; + result.Add(name, Hash(bytes)); + } + return result; + } + public static string[] Compare(IReadOnlyDictionary baseline, IReadOnlyDictionary candidate) => + baseline.Keys.Union(candidate.Keys, StringComparer.Ordinal).Order(StringComparer.Ordinal) + .Where(key => !baseline.TryGetValue(key, out string? before) || !candidate.TryGetValue(key, out string? after) || before != after).ToArray(); + public static void VerifyPackage(string path, string version, string root) + { + using var archive = ZipFile.OpenRead(path); + var nuspecEntry = archive.GetEntry("TeslaPowerwallLibrary.nuspec") ?? throw new InvalidDataException("Missing nuspec."); + using var input = nuspecEntry.Open(); var nuspec = XDocument.Load(input); + var metadata = nuspec.Root!.Elements().Single(e => e.Name.LocalName == "metadata"); + if (metadata.Elements().Single(e => e.Name.LocalName == "id").Value != "TeslaPowerwallLibrary" || metadata.Elements().Single(e => e.Name.LocalName == "version").Value != version) + throw new InvalidDataException("Unexpected package identity."); + if (metadata.Descendants().Where(e => e.Name.LocalName == "dependency").Any(e => ((string?)e.Attribute("id"))?.StartsWith("Cake.", StringComparison.OrdinalIgnoreCase) == true)) + throw new InvalidDataException("Build dependency leaked into the runtime package."); + void Exact(string entryName, string source) + { + using var stream = (archive.GetEntry(entryName) ?? throw new InvalidDataException("Missing " + entryName)).Open(); + using var buffer = new MemoryStream(); stream.CopyTo(buffer); + if (!buffer.ToArray().SequenceEqual(File.ReadAllBytes(source))) throw new InvalidDataException("Package bytes differ: " + entryName); + } + foreach (string framework in new[] { "net472", "net10.0" }) + foreach (string extension in new[] { "dll", "xml" }) + Exact($"lib/{framework}/TeslaPowerwallLibrary.{extension}", Path.Combine(root, "TeslaPowerwallLibrary/bin/Release", framework, "TeslaPowerwallLibrary." + extension)); + foreach (string document in BuildContext.Documents.Concat(["MIGRATION-SystemTextJson.md"])) Exact(document, Path.Combine(root, document)); + Exact("licenses/pypowerwall.txt", Path.Combine(root, "TeslaPowerwallLibrary/Tedapi/Protocol/UPSTREAM-LICENSE.txt")); + // SDK package/API validation remains enabled in the unchanged library project. + } +} diff --git a/build/Build.csproj b/build/Build.csproj new file mode 100644 index 0000000..2734b85 --- /dev/null +++ b/build/Build.csproj @@ -0,0 +1,16 @@ + + + Exe + net10.0 + enable + enable + false + true + + + + + + + + diff --git a/build/BuildContext.cs b/build/BuildContext.cs new file mode 100644 index 0000000..f8222b0 --- /dev/null +++ b/build/BuildContext.cs @@ -0,0 +1,103 @@ +// Copyright (c) 2026 Neil Colvin. MIT License; see ../TeslaPowerwallLibrary/LICENSE. +using System.Diagnostics; +using System.Text.Json; +using System.Text.RegularExpressions; +using System.Xml.Linq; +using Cake.Common; +using Cake.Core; +using Cake.Frosting; + +public sealed record TestSuite(string Name, string Project, string Framework); +public sealed record Application(string Name, string Project, string Framework); + +public sealed class BuildContext : FrostingContext +{ + public const string LibraryProject = "TeslaPowerwallLibrary/TeslaPowerwallLibrary.csproj"; + public const string Solution = "TeslaPowerwallLibrary.slnx"; + public const string OfflineFilter = "TestCategory!=Live"; + public static readonly TestSuite[] Suites = [ + new("library-net472", "TeslaPowerwallLibrary.Tests/TeslaPowerwallLibrary.Tests.csproj", "net472"), + new("library-net10", "TeslaPowerwallLibrary.Tests/TeslaPowerwallLibrary.Tests.csproj", "net10.0"), + new("console-net472", "TeslaPowerwallLibrary.TestConsole.Tests/TeslaPowerwallLibrary.TestConsole.Tests.csproj", "net472"), + new("console-net10", "TeslaPowerwallLibrary.TestConsole.Tests/TeslaPowerwallLibrary.TestConsole.Tests.csproj", "net10.0-windows"), + new("desktop", "TeslaPowerwallLibrary.App.Tests/TeslaPowerwallLibrary.App.Tests.csproj", "net10.0-windows"), + new("credentials", "TeslaPowerwallLibrary.TestCredentials.Tests/TeslaPowerwallLibrary.TestCredentials.Tests.csproj", "net10.0-windows")]; + public static readonly Application[] Applications = [ + new("TeslaPowerwallTestCredentials", "TeslaPowerwallLibrary.TestCredentials/TeslaPowerwallLibrary.TestCredentials.csproj", "net10.0-windows"), + new("TeslaPowerwallApp", "TeslaPowerwallLibrary.App/TeslaPowerwallLibrary.App.csproj", "net10.0-windows"), + new("TeslaPowerwallSetup", "TeslaPowerwallLibrary.Setup/TeslaPowerwallLibrary.Setup.csproj", "net10.0-windows"), + new("TeslaPowerwallTestConsole", "TeslaPowerwallLibrary.TestConsole/TeslaPowerwallLibrary.TestConsole.csproj", "net472"), + new("TeslaPowerwallTestConsole", "TeslaPowerwallLibrary.TestConsole/TeslaPowerwallLibrary.TestConsole.csproj", "net10.0-windows")]; + public static readonly string[] Documents = ["README.md", "CHANGELOG.md", "LOCAL-ACCESS.md", "UPGRADING-2.1.md", "RELEASE-NOTES.md"]; + public string Root { get; } + public string Version { get; } + public string Output { get; } + public string? Baseline { get; } + public TestSuite? SelectedSuite { get; } + public string Results => Path.Combine(Output, "test-results"); + public string Release => Path.Combine(Output, "release"); + public string NuGet => Path.Combine(Output, "nuget"); + private int _command; + public BuildContext(ICakeContext context) : base(context) + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory != null && !File.Exists(Path.Combine(directory.FullName, Solution))) directory = directory.Parent; + Root = directory?.FullName ?? throw new InvalidOperationException("Cannot find the repository solution above the build application."); + var project = XDocument.Load(Path.Combine(Root, LibraryProject)); + Version = context.Argument("version", project.Descendants("Version").Single().Value); + ValidateVersion(Version); + string run = DateTimeOffset.UtcNow.ToString("yyyyMMddTHHmmssZ") + "-" + Guid.NewGuid().ToString("N")[..8]; + Output = Path.GetFullPath(context.Argument("output", Path.Combine(Root, "artifacts", "cake-pilot", run)), Root); + string allowed = Path.Combine(Root, "artifacts", "cake-pilot") + Path.DirectorySeparatorChar; + if (!Output.StartsWith(allowed, StringComparison.OrdinalIgnoreCase)) throw new ArgumentException("Output must be a new directory beneath artifacts/cake-pilot/."); + Baseline = context.HasArgument("baseline") ? Path.GetFullPath(context.Argument("baseline"), Root) : null; + SelectedSuite = context.HasArgument("suite") ? ResolveSuite(context.Argument("suite"), context.Argument("framework", null)) : null; + } + public static TestSuite ResolveSuite(string name, string? framework = null) + { + var matches = Suites.Where(s => (s.Name == name || name == "powerwall" && s.Name.StartsWith("library-", StringComparison.Ordinal)) && (framework == null || s.Framework == framework)).ToArray(); + return matches.Length == 1 ? matches[0] : throw new ArgumentException("Select a known suite and matching framework from build/README.md."); + } + public string[] TestArguments(TestSuite suite, bool useBuiltBinaries) => + ["test", suite.Project, "--configuration", "Release", "--framework", suite.Framework, + .. (useBuiltBinaries ? new[] { "--no-build", "--no-restore" } : Array.Empty()), + "--filter", OfflineFilter, "--logger", "trx;LogFileName=" + suite.Name + ".trx", "--results-directory", Results, .. Properties]; + public static void ValidateVersion(string version) + { + if (!Regex.IsMatch(version, @"\A(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)\z", RegexOptions.CultureInvariant)) + throw new ArgumentException("Use a three-part numeric version, for example 2.3.2."); + } + public string[] Properties => ["-p:GenerateDocfxDocumentation=false", "-p:ContinuousIntegrationBuild=true", "-p:Version=" + Version]; + public void Record(string name, object data) + { + using var stream = new FileStream(Path.Combine(Output, name + ".json"), FileMode.CreateNew, FileAccess.Write); + JsonSerializer.Serialize(stream, data, new JsonSerializerOptions { WriteIndented = true }); + } + public void DotNet(params string[] arguments) => Execute("dotnet", arguments); + public void Execute(string executable, params string[] arguments) + { + int sequence = ++_command; + string prefix = Path.Combine(Output, $"command-{sequence:D2}"); + var start = new ProcessStartInfo(executable) { WorkingDirectory = Root, UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true }; + foreach (string argument in arguments) start.ArgumentList.Add(argument); + DateTimeOffset begun = DateTimeOffset.UtcNow; + Record($"command-{sequence:D2}-intent", new { executable, arguments, workingDirectory = Root, startedUtc = begun }); + Console.WriteLine($"> {executable} {string.Join(' ', arguments)}"); + using var process = Process.Start(start) ?? throw new InvalidOperationException("Cannot start " + executable); + using var log = TextWriter.Synchronized(new StreamWriter(prefix + ".log", append: false)); + async Task Copy(StreamReader reader) + { + while (await reader.ReadLineAsync() is { } line) { log.WriteLine(line); Console.WriteLine(line); } + } + Task stdout = Copy(process.StandardOutput), stderr = Copy(process.StandardError); + if (!process.WaitForExit((int)TimeSpan.FromMinutes(15).TotalMilliseconds)) + { + process.Kill(entireProcessTree: true); process.WaitForExit(); + Task.WaitAll(stdout, stderr); + throw new TimeoutException("Build command exceeded 15 minutes."); + } + Task.WaitAll(stdout, stderr); + Record($"command-{sequence:D2}-result", new { exitCode = process.ExitCode, completedUtc = DateTimeOffset.UtcNow, elapsedSeconds = (DateTimeOffset.UtcNow - begun).TotalSeconds }); + if (process.ExitCode != 0) throw new InvalidOperationException($"{executable} failed with exit code {process.ExitCode}; see {prefix}.log"); + } +} diff --git a/build/Program.cs b/build/Program.cs new file mode 100644 index 0000000..4580dc7 --- /dev/null +++ b/build/Program.cs @@ -0,0 +1,4 @@ +// Copyright (c) 2026 Neil Colvin. MIT License; see ../TeslaPowerwallLibrary/LICENSE. +using Cake.Frosting; + +return new CakeHost().UseContext().Run(args); diff --git a/build/README.md b/build/README.md new file mode 100644 index 0000000..64541cd --- /dev/null +++ b/build/README.md @@ -0,0 +1,135 @@ +# Cake Frosting pilot + +This is an optional Windows build of the existing solution, six offline desktop +test runs, the NuGet package and five application bundles. Cake.Frosting 6.3.0 is +a build-only dependency in this standalone project, outside the shipping solution. +Its PKCS dependency is pinned to patched 10.0.13; both are recorded in the build +project's package lockfile and do not change shipping runtime dependencies. +The public release workflow and its validation gates are unchanged. This pilot +has no publication, credential setup, live hardware or processor-test target. + +The existing `Unit tests` CI workflow now calls this project through a small local +composite action. Its six desktop test runs keep their original independent jobs +and matrix labels, and the credential job still builds the setup application. +The package-compatibility job uses `ReleaseArtifacts` to exercise package/API +validation and all five application bundles without duplicating those six runs. +The public publishing workflow and shared desktop/processor validation gates +are still unchanged. + +```powershell +dotnet run --project build/Build.csproj -c Release -- --target=Suite --suite=powerwall --framework=net472 +dotnet run --project build/Build.csproj -c Release -- --target=Suite --suite=console-net10 --framework=net10.0-windows +dotnet run --project build/Build.csproj -c Release -- --target=CredentialTools --suite=credentials +dotnet run --project build/Build.csproj -c Release -- --target=ReleaseArtifacts +``` + +Individual suite names are `library-net472`, `library-net10`, `console-net472`, +`console-net10`, `desktop` and `credentials`. The `powerwall` selector used by the +existing matrix requires a matching framework. Unknown or mismatched selectors +fail. `Suite` restores/builds only its selected test project; the full `Test` target +continues to reuse the one solution build. Both paths share commands, the Live +exclusion filter and a TRX readback that rejects zero-test or unsuccessful runs. +`ReleaseArtifacts` records `desktopTests: false`; it produces files only and is +not evidence that desktop or processor validation ran. It does not publish. + +From the repository root, using the .NET SDK selected by `global.json`: + +```powershell +dotnet restore build/Build.csproj --locked-mode +dotnet run --project build/Build.csproj -c Release --no-restore -- --target=Verify +``` + +The default target is also `Verify`. Each run creates a fresh directory beneath +`artifacts/cake-pilot/` and prints its path. Logs, command results, six TRX files, +NuGet output, application publish folders, release assets and a content manifest +are retained there. Existing output directories are rejected, never cleared. +Use `--output=artifacts/cake-pilot/my-run` to select a new directory, or +`--version=2.3.2` to set a three-part version; the default comes from the library +project. No version file is edited. + +Task sequence: + +```text +Initialize -> Restore -> Build -> Test -> Pack -----> Package -> Verify + \-> Applications -----/ +``` + +`Build` runs once in this graph. Test, pack and application publish reuse those +binaries. All test commands explicitly exclude the `Live` category. Pack retains +the library project's existing SDK package/API validation against the public +baseline. Build, pack and application publish receive the Release/CI/version +properties; restore retains the existing workflow's unversioned solution command. +Passing the release version into restore changes companion-project metadata in +project.assets.json and their generated deps files, so it is intentionally avoided +for this parity pilot. DocFX generation is disabled as in the release build. + +`Verify` checks package identity, both framework DLL/XML payloads, documentation +and the upstream notice against their source bytes; it also checks that Cake +has not become a runtime dependency. A failure stops subsequent tasks and leaves +the run evidence in place. The full solution includes WPF, Windows credential +tools and .NET Framework tests, so this pilot requires Windows. Concurrent runs +should use separate checkouts, because the existing projects share bin/obj paths. + +## Compare with the current workflow + +Build reference assets using the existing `dotnet-publish.yml` restore/build, +pack, application-publish and collect steps, with the same source, SDK and version. +Do not run its NuGet push or GitHub release steps. Point the pilot at the resulting +root containing `nuget/` and `release/`: + +```powershell +dotnet run --project build/Build.csproj -c Release --no-restore -- --target=Verify --baseline=artifacts/cake-reference --output=artifacts/cake-pilot/comparison +``` + +The comparison checks every release file and every uncompressed ZIP/NuGet entry +by SHA-256, reporting missing, added or changed entries. ZIP timestamps, compression +and entry order are container details and are not compared. NuGet's generated +OPC relationship IDs and core-properties filename are normalized; relationship +types/targets and the core-properties contents remain checked. This compares +unsigned local outputs, not the signature added by NuGet.org to a public package. +SDK/compiler differences may produce real binary differences and must be explained, +not automatically ignored. Comparison results are in `comparison.json`. + +```powershell +dotnet test build/Tests/Build.Tests.csproj -c Release +dotnet run --project build/Build.csproj -c Release -- --tree --target=Verify +``` + +The manual-only `Cake Frosting pilot` GitHub workflow calls the same build entry +point and retains evidence as an Actions artifact. It does not replace the current +test matrix, processor workflow, release locks, validation-reuse policy or publishing +path. Moving those entry points is a separate decision after this pilot is reviewed. + +## Local pilot result — 31 October 2026 + +Using SDK 10.0.403 and package version 2.3.2, the full target completed successfully +in 52.5 seconds with warm build outputs. This is a local execution time, not a +measurement of GitHub runner provisioning or hardware-test time. + +| Offline suite | Passed | +| --- | ---: | +| Library / net472 | 511 | +| Library / net10.0 | 511 | +| Console / net472 | 51 | +| Console / net10.0-windows | 51 | +| Desktop / net10.0-windows | 126 | +| Credential tools / net10.0-windows | 59 | +| **Total** | **1,309** | + +The ten build-tool tests passed. SDK package/API validation passed. One NuGet +package and 17 release files (including five application archives) were produced; +all 228 normalized manifest entries matched a clean reference generated using the +existing release commands. No DLL, dependency JSON, notice or document difference +was excluded from comparison. Generated OPC identifiers and ZIP container metadata +are the only comparison normalizations described above. + +Local evidence is retained in `artifacts/cake-pilot/verified-20261031/` and +`artifacts/cake-reference-20261031-clean/`. Earlier failed comparisons are retained +in the other dated directories. They exposed a restore-property difference and +cached dependency files in the original warm reference; the clean reference +eliminated the latter without changing the comparison rules. + +These are the original local-pilot results, before CI integration. The manual +workflow had not been dispatched and there was no live/processor testing, public +upload, commit, push or release during that first validation. Later CI evidence +is recorded separately in the development history and GitHub run linked there. diff --git a/build/Tasks.cs b/build/Tasks.cs new file mode 100644 index 0000000..dcf0f75 --- /dev/null +++ b/build/Tasks.cs @@ -0,0 +1,166 @@ +// Copyright (c) 2026 Neil Colvin. MIT License; see ../TeslaPowerwallLibrary/LICENSE. +using System.IO.Compression; +using System.Xml.Linq; +using Cake.Frosting; + +[TaskName("Initialize")] +public sealed class Initialize : FrostingTask +{ + public override void Run(BuildContext context) + { + if (!OperatingSystem.IsWindows()) throw new PlatformNotSupportedException("This full-solution pilot includes WPF and .NET Framework tests and requires Windows."); + if (Directory.Exists(context.Output) || File.Exists(context.Output)) throw new IOException("Preserve the existing run; choose a new --output directory."); + Directory.CreateDirectory(context.Output); + context.Record("run", new { version = context.Version, repository = context.Root, output = context.Output, baseline = context.Baseline, startedUtc = DateTimeOffset.UtcNow, liveTests = false, processorTests = false, publication = false }); + Directory.CreateDirectory(context.Results); + context.DotNet("--version"); + } +} + +[TaskName("Restore"), IsDependentOn(typeof(Initialize))] +public sealed class Restore : FrostingTask +{ + // Preserve the existing workflow's restore evaluation: companion project + // versions in project.assets.json otherwise change their generated deps files. + public override void Run(BuildContext context) => context.DotNet("restore", BuildContext.Solution); +} + +[TaskName("Build"), IsDependentOn(typeof(Restore))] +public sealed class Build : FrostingTask +{ + public override void Run(BuildContext context) => context.DotNet(["build", BuildContext.Solution, "--configuration", "Release", "--no-restore", .. context.Properties]); +} + +[TaskName("Test"), IsDependentOn(typeof(Build))] +public sealed class Test : FrostingTask +{ + public override void Run(BuildContext context) + { + foreach (var suite in BuildContext.Suites) + context.DotNet(context.TestArguments(suite, useBuiltBinaries: true)); + var results = BuildContext.Suites.Select(suite => ReadResult(context.Results, suite)).ToArray(); + context.Record("desktop-tests", new { filter = BuildContext.OfflineFilter, suites = results, passed = results.Sum(r => r.passed), processorTests = false }); + } + public static SuiteResult ReadResult(string results, TestSuite suite) + { + var trx = XDocument.Load(Path.Combine(results, suite.Name + ".trx")); + var counters = trx.Descendants().Single(e => e.Name.LocalName == "Counters"); + int passed = (int)counters.Attribute("passed")!, failed = (int)counters.Attribute("failed")!, executed = (int)counters.Attribute("executed")!; + if (passed == 0 || failed != 0 || passed != executed) throw new InvalidDataException("Incomplete or unsuccessful test run: " + suite.Name); + return new(suite.Name, suite.Framework, passed, failed, executed); + } +} +public sealed record SuiteResult(string suite, string Framework, int passed, int failed, int executed); + +[TaskName("Suite"), IsDependentOn(typeof(Initialize))] +public sealed class Suite : FrostingTask +{ + public override void Run(BuildContext context) + { + var selected = context.SelectedSuite ?? throw new ArgumentException("Suite requires --suite."); + context.DotNet(context.TestArguments(selected, useBuiltBinaries: false)); + context.Record("desktop-tests", new { filter = BuildContext.OfflineFilter, suites = new[] { Test.ReadResult(context.Results, selected) }, processorTests = false }); + } +} + +[TaskName("CredentialTools"), IsDependentOn(typeof(Suite))] +public sealed class CredentialTools : FrostingTask +{ + public override void Run(BuildContext context) + { + if (context.SelectedSuite?.Name != "credentials") throw new ArgumentException("CredentialTools requires --suite=credentials."); + context.DotNet(["build", "TeslaPowerwallLibrary.Setup/TeslaPowerwallLibrary.Setup.csproj", "--configuration", "Release", .. context.Properties]); + } +} + +[TaskName("Pack"), IsDependentOn(typeof(Test))] +public sealed class Pack : FrostingTask +{ + public override void Run(BuildContext context) + => PackLibrary(context); + public static void PackLibrary(BuildContext context) + { + Directory.CreateDirectory(context.NuGet); + context.DotNet(["pack", BuildContext.LibraryProject, "--configuration", "Release", "--no-build", "--output", context.NuGet, "-p:PackageVersion=" + context.Version, .. context.Properties]); + } +} + +[TaskName("Applications"), IsDependentOn(typeof(Build))] +public sealed class Applications : FrostingTask +{ + public override void Run(BuildContext context) + { + foreach (var app in BuildContext.Applications) + context.DotNet(["publish", app.Project, "--configuration", "Release", "--no-restore", "--no-build", "--framework", app.Framework, + "--output", Path.Combine(context.Output, "publish", app.Name + "-" + app.Framework), .. context.Properties]); + } +} + +[TaskName("Package"), IsDependentOn(typeof(Pack)), IsDependentOn(typeof(Applications))] +public sealed class Package : FrostingTask +{ + public override void Run(BuildContext context) + => Collect(context); + public static void Collect(BuildContext context) + { + Directory.CreateDirectory(context.Release); + foreach (string document in BuildContext.Documents) File.Copy(Path.Combine(context.Root, document), Path.Combine(context.Release, document)); + File.Copy(Path.Combine(context.Root, "TeslaPowerwallLibrary/Tedapi/Protocol/UPSTREAM-LICENSE.txt"), Path.Combine(context.Release, "pypowerwall-LICENSE.txt")); + foreach (var (project, frameworks) in new[] { ("TeslaPowerwallLibrary", new[] { "net472", "net10.0" }), ("TeslaPowerwallLibrary.Login", new[] { "net472", "net10.0-windows" }) }) + foreach (string framework in frameworks) + foreach (string extension in new[] { "dll", "xml" }) + { + string source = Path.Combine(context.Root, project, "bin", "Release", framework, project + "." + extension); + if (File.Exists(source)) File.Copy(source, Path.Combine(context.Release, project + "." + framework + "." + extension)); + else if (extension == "dll" || project == "TeslaPowerwallLibrary") throw new FileNotFoundException("Required release file missing.", source); + } + foreach (var app in BuildContext.Applications) + { + string name = app.Name + "-" + app.Framework; + ZipFile.CreateFromDirectory(Path.Combine(context.Output, "publish", name), Path.Combine(context.Release, name + ".zip")); + } + } +} + +[TaskName("Verify"), IsDependentOn(typeof(Package))] +public sealed class Verify : FrostingTask +{ + public override void Run(BuildContext context) + => Complete(context, desktopTests: true); + public static void Complete(BuildContext context, bool desktopTests) + { + string package = Path.Combine(context.NuGet, "TeslaPowerwallLibrary." + context.Version + ".nupkg"); + ArtifactVerification.VerifyPackage(package, context.Version, context.Root); + var current = ArtifactVerification.Inventory(context.Output); + context.Record("artifact-manifest", current); + if (context.Baseline != null) + { + var comparison = ArtifactVerification.Compare(ArtifactVerification.Inventory(context.Baseline), current); + context.Record("comparison", new { baseline = context.Baseline, differences = comparison, equivalent = comparison.Length == 0, zipContainerBytesCompared = false, payloadBytesCompared = true }); + if (comparison.Length != 0) throw new InvalidDataException("Artifact comparison failed: " + string.Join(", ", comparison)); + } + context.Record("completed", new { version = context.Version, completedUtc = DateTimeOffset.UtcNow, packageValidation = true, desktopTests, applicationBundles = 5, comparisonPerformed = context.Baseline != null, publication = false }); + Console.WriteLine("Verified pilot artifacts: " + context.Output); + } +} + +[TaskName("ReleasePack"), IsDependentOn(typeof(Build))] +public sealed class ReleasePack : FrostingTask +{ + public override void Run(BuildContext context) => Pack.PackLibrary(context); +} + +// Produces files only. Public publication still requires the YAML validation gate, +// collision checks, permissions and publication lock; this target sends nothing. +[TaskName("ReleaseArtifacts"), IsDependentOn(typeof(ReleasePack)), IsDependentOn(typeof(Applications))] +public sealed class ReleaseArtifacts : FrostingTask +{ + public override void Run(BuildContext context) + { + Package.Collect(context); + Verify.Complete(context, desktopTests: false); + } +} + +[TaskName("Default"), IsDependentOn(typeof(Verify))] +public sealed class Default : FrostingTask; diff --git a/build/Tests/ArtifactTests.cs b/build/Tests/ArtifactTests.cs new file mode 100644 index 0000000..7433521 --- /dev/null +++ b/build/Tests/ArtifactTests.cs @@ -0,0 +1,88 @@ +// Copyright (c) 2026 Neil Colvin. MIT License; see ../../TeslaPowerwallLibrary/LICENSE. +using System.IO.Compression; +using System.Reflection; +using Cake.Frosting; +using NUnit.Framework; + +public sealed class ArtifactTests +{ + private string _directory = null!; + [SetUp] public void CreateDirectory() => _directory = Path.Combine(Path.GetTempPath(), "powerwall-cake-" + Guid.NewGuid().ToString("N")); + [TearDown] public void RemoveDirectory() { if (Directory.Exists(_directory)) Directory.Delete(_directory, recursive: true); } + private string Archive(string name, DateTimeOffset timestamp, params (string Name, string Content)[] entries) + { + Directory.CreateDirectory(_directory); string path = Path.Combine(_directory, name); + using var zip = ZipFile.Open(path, ZipArchiveMode.Create); + foreach (var (entryName, content) in entries) + { + var entry = zip.CreateEntry(entryName); entry.LastWriteTime = timestamp; + using var writer = new StreamWriter(entry.Open()); writer.Write(content); + } + return path; + } + [Test] public void ChangedRuntimePayloadFailsComparison() + { + string before = Archive("before.zip", DateTimeOffset.Now, ("library.dll", "original")); + string after = Archive("after.zip", DateTimeOffset.Now, ("library.dll", "changed")); + Assert.That(ArtifactVerification.Compare(ArtifactVerification.Archive(before), ArtifactVerification.Archive(after)), Is.EqualTo(new[] { "library.dll" })); + } + [Test] public void MissingNoticeFailsComparison() + { + string before = Archive("before.zip", DateTimeOffset.Now, ("library.dll", "same"), ("LICENSE.txt", "notice")); + string after = Archive("after.zip", DateTimeOffset.Now, ("library.dll", "same")); + Assert.That(ArtifactVerification.Compare(ArtifactVerification.Archive(before), ArtifactVerification.Archive(after)), Is.EqualTo(new[] { "LICENSE.txt" })); + } + [Test] public void DifferentZipTimestampsAndOrderRetainEquivalentPayload() + { + string before = Archive("before.zip", DateTimeOffset.Now, ("library.dll", "same"), ("LICENSE.txt", "notice")); + string after = Archive("after.zip", DateTimeOffset.Now.AddDays(-5), ("LICENSE.txt", "notice"), ("library.dll", "same")); + Assert.That(ArtifactVerification.Compare(ArtifactVerification.Archive(before), ArtifactVerification.Archive(after)), Is.Empty); + } + [Test] public void DuplicateArchiveNamesAreRejected() + { + string path = Archive("duplicate.zip", DateTimeOffset.Now, ("library.dll", "first"), ("library.dll", "second")); + Assert.Throws(() => ArtifactVerification.Archive(path)); + } + [TestCase("2.3.2.0"), TestCase("v2.3.2"), TestCase("2.3"), TestCase("2.03.2"), TestCase("2.3.2 -p:Other=true")] + public void InvalidReleaseVersionIsRejected(string version) => Assert.Throws(() => BuildContext.ValidateVersion(version)); + [Test] public void NuGetRelationshipIdsDoNotMaskChangedTargets() + { + const string part = "package/services/metadata/core-properties/nuget.psmdcp"; + string Make(string name, string id, string target) => Archive(name, DateTimeOffset.Now, + (part, "same"), ("_rels/.rels", $"")); + var before = ArtifactVerification.Archive(Make("before.nupkg", "first", "/library.nuspec")); + var same = ArtifactVerification.Archive(Make("same.nupkg", "second", "/library.nuspec")); + var changed = ArtifactVerification.Archive(Make("changed.nupkg", "third", "/wrong.nuspec")); + Assert.That(ArtifactVerification.Compare(before, same), Is.Empty); + Assert.That(ArtifactVerification.Compare(before, changed), Is.EqualTo(new[] { "_rels/.rels" })); + } + [TestCase("powerwall", "net472", "library-net472")] + [TestCase("powerwall", "net10.0", "library-net10")] + [TestCase("console-net10", "net10.0-windows", "console-net10")] + public void CiSelectorsResolveTheExpectedSuite(string name, string framework, string expected) => Assert.That(BuildContext.ResolveSuite(name, framework).Name, Is.EqualTo(expected)); + [TestCase("powerwall", null), TestCase("powerwall", "net8.0"), TestCase("console-net10", "net472"), TestCase("unrecognised", null)] + public void AmbiguousOrMismatchedCiSelectorsFail(string name, string? framework) => Assert.Throws(() => BuildContext.ResolveSuite(name, framework)); + [Test] public void ArtifactPreparationDoesNotRerunDesktopTests() + { + static HashSet Dependencies(Type task) + { + var found = new HashSet(); + void Visit(Type current) + { + foreach (var dependency in current.GetCustomAttributes()) if (found.Add(dependency.Task)) Visit(dependency.Task); + } + Visit(task); return found; + } + var artifacts = Dependencies(typeof(ReleaseArtifacts)); + Assert.That(artifacts, Does.Contain(typeof(Build)).And.Contain(typeof(Applications)).And.Contain(typeof(ReleasePack))); + Assert.That(artifacts, Does.Not.Contain(typeof(Test)).And.Not.Contain(typeof(Suite)).And.Not.Contain(typeof(Pack))); + Assert.That(Dependencies(typeof(Verify)), Does.Contain(typeof(Test))); + } + [Test] public void EmptyTestRunCannotBeReportedAsPassed() + { + Directory.CreateDirectory(_directory); + var suite = BuildContext.Suites[0]; + File.WriteAllText(Path.Combine(_directory, suite.Name + ".trx"), ""); + Assert.Throws(() => Test.ReadResult(_directory, suite)); + } +} diff --git a/build/Tests/Build.Tests.csproj b/build/Tests/Build.Tests.csproj new file mode 100644 index 0000000..c50e68b --- /dev/null +++ b/build/Tests/Build.Tests.csproj @@ -0,0 +1,15 @@ + + + net10.0 + enable + enable + true + false + + + + + + + + diff --git a/build/packages.lock.json b/build/packages.lock.json new file mode 100644 index 0000000..2d5f8aa --- /dev/null +++ b/build/packages.lock.json @@ -0,0 +1,245 @@ +{ + "version": 1, + "dependencies": { + "net10.0": { + "Cake.Frosting": { + "type": "Direct", + "requested": "[6.3.0, )", + "resolved": "6.3.0", + "contentHash": "yeKBxZkGkwcqV3GmvV40oke3T1hSeeaApgFA1VdYFCuyErdW+dqpPo5IHbPcMbKuZ+KOhg2Qe/15rRXjwHYXTg==", + "dependencies": { + "Autofac": "9.3.2", + "Cake.Cli": "6.3.0", + "Cake.Common": "6.3.0", + "Cake.Core": "6.3.0", + "Cake.DotNetTool.Module": "6.3.0", + "Cake.NuGet": "6.3.0", + "Microsoft.Extensions.DependencyInjection": "10.0.12", + "Microsoft.IdentityModel.JsonWebTokens": "8.22.0", + "Newtonsoft.Json": "13.0.4", + "NuGet.Common": "7.9.0", + "NuGet.Credentials": "7.9.0", + "NuGet.Frameworks": "7.9.0", + "NuGet.Packaging": "7.9.0", + "NuGet.Protocol": "7.9.0", + "NuGet.Resolver": "7.9.0", + "NuGet.Versioning": "7.9.0", + "Spectre.Console": "0.57.2", + "Spectre.Console.Cli": "0.55.0", + "System.Security.Cryptography.Pkcs": "10.0.12" + } + }, + "System.Security.Cryptography.Pkcs": { + "type": "Direct", + "requested": "[10.0.13, )", + "resolved": "10.0.13", + "contentHash": "a+EovgC1fBnwdlQ6cn3j4JFBfKqiFaFvoAhjisOoSvazLAcIznaBzPQP0oYnvccmeXfqUH1KebVZOxSeH6rMaw==" + }, + "Autofac": { + "type": "Transitive", + "resolved": "9.3.2", + "contentHash": "an3dLpmXWvb2aBP5qyA7GVEcXhoG+W/EtFRa3sBYzJdXSNDnB0aoeo4MgOQru0G5sjNDGYM3D3CnBzNFXEVOOA==" + }, + "Cake.Cli": { + "type": "Transitive", + "resolved": "6.3.0", + "contentHash": "gk3XSSRJu6F3UlrucHknDWTZ661NQyTv6FIYcIm8w5ddlGn3WTmtQXrK8kqzfhCRmXhfQzMpWFi6QY+aQOzZ3Q==", + "dependencies": { + "Autofac": "9.3.2", + "Cake.Core": "6.3.0", + "Spectre.Console": "0.57.2", + "Spectre.Console.Cli": "0.55.0" + } + }, + "Cake.Common": { + "type": "Transitive", + "resolved": "6.3.0", + "contentHash": "r1+MVDKWQxPZVxxSHOQegqQNnEFIArbX+TQwZlLtTxhQ3utg5NdqNBAmKidD7zL4TRRFUINpbDtuFwLeSIXoTA==", + "dependencies": { + "Cake.Core": "6.3.0", + "Microsoft.IdentityModel.JsonWebTokens": "8.22.0" + } + }, + "Cake.Core": { + "type": "Transitive", + "resolved": "6.3.0", + "contentHash": "cjGdNu45pU4xseBjKJd68FevNeLhyBI7k6YUImNKLox6aoZLu55a90yaXyAmH7gdC7wpxFH6dyfVp7MxTn688A==" + }, + "Cake.DotNetTool.Module": { + "type": "Transitive", + "resolved": "6.3.0", + "contentHash": "3GFQZ8YtWNUArlF2tuVpCwSOzpManeK/upWc8w/bUV5f9a9gApmF37rEkbqcJKRJ2KTCHy/5BwfrVDAvA40MAg==", + "dependencies": { + "Cake.Core": "6.3.0" + } + }, + "Cake.NuGet": { + "type": "Transitive", + "resolved": "6.3.0", + "contentHash": "FQ2uXLN55HhBFp65WROCLuLM1esnCkdzMshZ4T519T4y+WtfVdZirw0dt4dsjRcjBl9Dcob1fF+JmjF7A8Olww==", + "dependencies": { + "Cake.Core": "6.3.0", + "Newtonsoft.Json": "13.0.4", + "NuGet.Common": "7.9.0", + "NuGet.Credentials": "7.9.0", + "NuGet.Frameworks": "7.9.0", + "NuGet.Packaging": "7.9.0", + "NuGet.Protocol": "7.9.0", + "NuGet.Resolver": "7.9.0", + "NuGet.Versioning": "7.9.0", + "System.Security.Cryptography.Pkcs": "10.0.12" + } + }, + "Microsoft.Bcl.Cryptography": { + "type": "Transitive", + "resolved": "10.0.2", + "contentHash": "LG9Yll3B5aNpxv0+D47g6LiOiKBIlodhcHdQwcYzo8VeexFLGqx5ymetmA2aBRyo9cCcWsQWrFsdbsr8LvmWDw==" + }, + "Microsoft.Extensions.DependencyInjection": { + "type": "Transitive", + "resolved": "10.0.12", + "contentHash": "lXyK2O5GoYvfxW8eCFcD16JFbcoSTM1sJkAM0UHS1jZyl9NYMW64Tqm6OQFT0IDBjZi+xHt95/Zg+nxZhGFhZg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "10.0.12", + "contentHash": "9/qymSh7hVDMGTGwrLz8MRp5zRyXy9adGDOs4HwRdnLil3oZGYuWeZjbmHgCQ9BL1qBroVfgUK3U/nb61617Cw==" + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "arDBqTgFCyS0EvRV7O3MZturChstm50OJ0y9bDJvAcmEPJm0FFpFyjU/JLYyStNGGey081DvnQYlncNX5SJJGA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0" + } + }, + "Microsoft.IdentityModel.Abstractions": { + "type": "Transitive", + "resolved": "8.22.0", + "contentHash": "LU3V3owsu4vGpCg2kyL7SsQEuHwcoJ8FSNBqzLADzCf3/PcKUTcx5Plsd51DoTJMfK/WigXV/03UhaN5JXE6uQ==" + }, + "Microsoft.IdentityModel.JsonWebTokens": { + "type": "Transitive", + "resolved": "8.22.0", + "contentHash": "kv6peMLjALZLDAy2H3F77KjVRdwiscn2p/g3ui2chcbuEcAX2MpAbyDcYnJ7Vyh8jZ1aJWrniUMCDWoOgnu4NQ==", + "dependencies": { + "Microsoft.IdentityModel.Tokens": "8.22.0" + } + }, + "Microsoft.IdentityModel.Logging": { + "type": "Transitive", + "resolved": "8.22.0", + "contentHash": "G9Tl0yXSlr2pkXv4EpXjO16M4q6oo9N/od+gNyOusZ8yM8LZg1H3f/QOMFuOJiV6znzY5MkAREU97JRRnqpEQw==", + "dependencies": { + "Microsoft.IdentityModel.Abstractions": "8.22.0" + } + }, + "Microsoft.IdentityModel.Tokens": { + "type": "Transitive", + "resolved": "8.22.0", + "contentHash": "i4lywKKUuVmheCUA+w/q8QNPReNI0qanHI9hhz48AFqD1ljyb8sxPL2RbXOGiPV13XdJ4kxieL9ukS7tD43LxA==", + "dependencies": { + "Microsoft.Bcl.Cryptography": "10.0.2", + "Microsoft.Extensions.Logging.Abstractions": "8.0.0", + "Microsoft.IdentityModel.Logging": "8.22.0" + } + }, + "Newtonsoft.Json": { + "type": "Transitive", + "resolved": "13.0.4", + "contentHash": "pdgNNMai3zv51W5aq268sujXUyx7SNdE2bj1wZcWjAQrKMFZV260lbqYop1d2GM67JI1huLRwxo9ZqnfF/lC6A==" + }, + "NuGet.Common": { + "type": "Transitive", + "resolved": "7.9.0", + "contentHash": "OWFEzHpwvgzSaHYmWzfbyTm6oBOy/BFSFEDk73zM9jiJaxdopYZ/DrqIQogaM3F+ZGTppBJffmzu5adetEI/YA==", + "dependencies": { + "NuGet.Frameworks": "7.9.0" + } + }, + "NuGet.Configuration": { + "type": "Transitive", + "resolved": "7.9.0", + "contentHash": "oY9XZp+TWjOmiGmU4R4JWBRY3F4TAVo7tqPr/z7MOsFcEpdYHMryjNSscoLBxUTR3WmFmYgO0Gvu4eRbz6pzOg==", + "dependencies": { + "NuGet.Common": "7.9.0", + "System.Security.Cryptography.ProtectedData": "8.0.0" + } + }, + "NuGet.Credentials": { + "type": "Transitive", + "resolved": "7.9.0", + "contentHash": "K+etX+2rEk7nLVd3a6vA/7hjARtiDORoaGgIUYul4hj3VSXC1Icj+PSdBygjQ0c4CmUC3i+Zqoaz7F1EwD+DBw==", + "dependencies": { + "NuGet.Protocol": "7.9.0" + } + }, + "NuGet.Frameworks": { + "type": "Transitive", + "resolved": "7.9.0", + "contentHash": "w2adPmgUafmW9ON1MzDjuHmViN0vtiPV48Dz3OE6px8BiGSqrnWHOjwFEPOEPAZx20J1c7bxp90Hxo9l4xGWtw==" + }, + "NuGet.Packaging": { + "type": "Transitive", + "resolved": "7.9.0", + "contentHash": "Q2jxuud5kJLV0rUzsC02iC5ts8QsQu9UVyMvF8lULY3TwHL/W/PrnQ9bQFvMR7jJkzF1uilRPI1o+7VOtc2XMg==", + "dependencies": { + "Newtonsoft.Json": "13.0.3", + "NuGet.Configuration": "7.9.0", + "NuGet.Versioning": "7.9.0", + "System.Security.Cryptography.Pkcs": "8.0.1" + } + }, + "NuGet.Protocol": { + "type": "Transitive", + "resolved": "7.9.0", + "contentHash": "Ts0qPUGJYfFHhlNSSWWrkaSG6++WqS6ry32WmQA7eNdzTc9SumyhDflITcusBYkT6pEQD5BI1UEByakciV3s0A==", + "dependencies": { + "NuGet.Packaging": "7.9.0" + } + }, + "NuGet.Resolver": { + "type": "Transitive", + "resolved": "7.9.0", + "contentHash": "AeambHAB0Cq/BFR84mQvV92kPJAglFt8eVQTAYey58wEfgQaajk/RysHJVX3wP9Hr7/NfpDLIIsJdVH4Yub+og==", + "dependencies": { + "NuGet.Protocol": "7.9.0" + } + }, + "NuGet.Versioning": { + "type": "Transitive", + "resolved": "7.9.0", + "contentHash": "r4sZuVpVqwSBLe4qYDrnpScqdyctlRXg0HSF5OoCGkeBc8b+o8BBYHuI/u1RoWYX78PbieijU13TBt71/Xf3lw==" + }, + "Spectre.Console": { + "type": "Transitive", + "resolved": "0.57.2", + "contentHash": "wzsB+P9i6F9G+cFOJxRotCiQWNSzZVAMxi8YMiHlGXXU1JLMXGp7zSSqBMmROZS2bzpK5jk+Saa7Evp8HwqhKg==", + "dependencies": { + "Spectre.Console.Ansi": "0.57.2" + } + }, + "Spectre.Console.Ansi": { + "type": "Transitive", + "resolved": "0.57.2", + "contentHash": "Y1+u73shwP+JYHmrkdN6bSt2kaGBEAafhi2nrecczbXdFRey+k7J/WF9YPfcB0rqivv2S8fmif6FSBq9m/AFvw==" + }, + "Spectre.Console.Cli": { + "type": "Transitive", + "resolved": "0.55.0", + "contentHash": "cFJTB1te4eaAICd4Zp+8T/kiRn1ikQxgGqmoNarb1qoQ/7LzHAvVQ62x5U9GW3E0QI7sZLcXIIoq4CpqgTx42Q==", + "dependencies": { + "Spectre.Console": "0.55.0" + } + }, + "System.Security.Cryptography.ProtectedData": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "+TUFINV2q2ifyXauQXRwy4CiBhqvDEDZeVJU7qfxya4aRYOKzVBpN+4acx25VcPB9ywUN6C0n8drWl110PhZEg==" + } + } + } +} \ No newline at end of file