From 515122e55b3a1798c43e3fb76f42e302f8d0782c Mon Sep 17 00:00:00 2001 From: Steven Cady Date: Mon, 5 Oct 2026 08:35:34 -0500 Subject: [PATCH 01/10] Keep each pinned version in one place, and build a non-root runtime image NuGet versions move into Directory.Packages.props (central package management), so a bump changes one line. The pin guards now check that copies agree instead of asserting literals: the Dockerfile's SDK stage matches global.json, the image runs on that SDK's runtime line, the EF Core packages and the dotnet-ef tool share a version, and the DotRecast pair agree. An update that moves every copy passes; one that misses a copy fails. The Dockerfile builds in sdk:10.0.401 and runs in runtime:10.0 as the image's non-root app user (UID 1654), keeping the output paths compose uses. The container model learns FROM stages, COPY --from/--chown/--chmod and USER; other COPY flags are still rejected. Refs #132 (CI-4, CI-7). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AxABTHjs6nJrsXVCTnmMo7 --- Directory.Packages.props | 34 +++++ Dockerfile | 17 ++- src/Rasa.Auth/Rasa.Auth.csproj | 8 +- src/Rasa.DBL/Rasa.DBL.csproj | 10 +- src/Rasa.Game/Rasa.Game.csproj | 6 +- src/Rasa.NavMesh/Rasa.NavMesh.csproj | 2 +- src/Rasa.Navigation/Rasa.Navigation.csproj | 2 +- .../Compatibility/ContainerLayoutModels.cs | 138 +++++++++++++++++- .../ContainerLayoutModelsTests.cs | 93 ++++++++++++ .../PlatformCompatibilityTests.cs | 42 +++++- src/Rasa.Test/Compatibility/RepositoryPins.cs | 106 ++++++++++++++ .../Compatibility/RepositoryPinsTests.cs | 120 +++++++++++++++ src/Rasa.Test/Rasa.Test.csproj | 8 +- src/Rasa.Utils/Rasa.Utils.csproj | 8 +- 14 files changed, 555 insertions(+), 39 deletions(-) create mode 100644 Directory.Packages.props create mode 100644 src/Rasa.Test/Compatibility/RepositoryPins.cs create mode 100644 src/Rasa.Test/Compatibility/RepositoryPinsTests.cs diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 00000000..4b7a573a --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,34 @@ + + + + + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Dockerfile b/Dockerfile index e0870e94..8977a000 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,7 @@ -FROM mcr.microsoft.com/dotnet/sdk:10.0.401 +# Build in the SDK image global.json pins, run in the much smaller runtime image as its built-in +# non-root user. The runtime stage keeps the build's output paths, so docker-compose.yml's working +# directories and volume mounts are the same in both. +FROM mcr.microsoft.com/dotnet/sdk:10.0.401 AS build WORKDIR /app @@ -6,10 +9,20 @@ COPY src /app/src COPY Rasa.NET.sln /app COPY Rasa.NET.sln.DotSettings /app COPY global.json /app +COPY Directory.Packages.props /app COPY .config /app/.config ARG NUGET_SOURCE=https://api.nuget.org/v3/index.json RUN dotnet restore --source "$NUGET_SOURCE" RUN dotnet build --no-restore --configuration Release -COPY navmesh /app/src/Rasa.Game/bin/Release/net10.0/navmesh +FROM mcr.microsoft.com/dotnet/runtime:10.0 AS runtime + +WORKDIR /app + +# Owned by app (UID 1654) so SQLite can create its journal files beside the mounted databases. +COPY --from=build --chown=app:app /app/src/Rasa.Auth/bin/Release/net10.0 /app/src/Rasa.Auth/bin/Release/net10.0 +COPY --from=build --chown=app:app /app/src/Rasa.Game/bin/Release/net10.0 /app/src/Rasa.Game/bin/Release/net10.0 +COPY --chown=app:app navmesh /app/src/Rasa.Game/bin/Release/net10.0/navmesh + +USER app diff --git a/src/Rasa.Auth/Rasa.Auth.csproj b/src/Rasa.Auth/Rasa.Auth.csproj index ef4cb426..cbaaea80 100644 --- a/src/Rasa.Auth/Rasa.Auth.csproj +++ b/src/Rasa.Auth/Rasa.Auth.csproj @@ -32,10 +32,10 @@ - - - - + + + + diff --git a/src/Rasa.DBL/Rasa.DBL.csproj b/src/Rasa.DBL/Rasa.DBL.csproj index 0492607a..d2e94547 100644 --- a/src/Rasa.DBL/Rasa.DBL.csproj +++ b/src/Rasa.DBL/Rasa.DBL.csproj @@ -18,14 +18,14 @@ - - - + + + all runtime; build; native; contentfiles; analyzers; buildtransitive - - + + diff --git a/src/Rasa.Game/Rasa.Game.csproj b/src/Rasa.Game/Rasa.Game.csproj index 3592bf78..9a2212d5 100644 --- a/src/Rasa.Game/Rasa.Game.csproj +++ b/src/Rasa.Game/Rasa.Game.csproj @@ -16,12 +16,12 @@ - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/src/Rasa.NavMesh/Rasa.NavMesh.csproj b/src/Rasa.NavMesh/Rasa.NavMesh.csproj index 010a64f4..9d846e0d 100644 --- a/src/Rasa.NavMesh/Rasa.NavMesh.csproj +++ b/src/Rasa.NavMesh/Rasa.NavMesh.csproj @@ -17,7 +17,7 @@ - + diff --git a/src/Rasa.Navigation/Rasa.Navigation.csproj b/src/Rasa.Navigation/Rasa.Navigation.csproj index 7b215f0c..a4c05a8f 100644 --- a/src/Rasa.Navigation/Rasa.Navigation.csproj +++ b/src/Rasa.Navigation/Rasa.Navigation.csproj @@ -14,7 +14,7 @@ - + diff --git a/src/Rasa.Test/Compatibility/ContainerLayoutModels.cs b/src/Rasa.Test/Compatibility/ContainerLayoutModels.cs index 3ec5157e..96017ab6 100644 --- a/src/Rasa.Test/Compatibility/ContainerLayoutModels.cs +++ b/src/Rasa.Test/Compatibility/ContainerLayoutModels.cs @@ -201,20 +201,58 @@ internal sealed class DockerImageLayout private readonly Dictionary _copiedHostFiles = new Dictionary(StringComparer.Ordinal); - private DockerImageLayout() + internal string BaseImage { get; } + internal string User { get; private set; } = "root"; + + private DockerImageLayout(string baseImage) { + BaseImage = baseImage; } + // The layout of the Dockerfile's final stage. Earlier stages are modelled too, so a + // COPY --from= takes exactly the files that stage would hold. internal static DockerImageLayout Create(string repositoryRoot) { - var layout = new DockerImageLayout(); + return CreateStages(repositoryRoot).Last(); + } + + internal static IReadOnlyList CreateStages(string repositoryRoot) + { var dockerfile = DockerfileModel.Parse( File.ReadAllText(Path.Combine(repositoryRoot, "Dockerfile"))); var dockerIgnore = DockerIgnoreMatcher.Load(repositoryRoot); + var stages = new List(); + var stageNames = new Dictionary( + StringComparer.OrdinalIgnoreCase); + DockerImageLayout layout = null; var workingDirectory = "/"; foreach (var instruction in dockerfile.Instructions) { + if (instruction.Name == "FROM") + { + var from = SplitArguments(instruction.Arguments); + if (from.Count != 1 && + !(from.Count == 3 && from[1].Equals("AS", StringComparison.OrdinalIgnoreCase))) + throw new InvalidDataException($"Unsupported Docker FROM instruction: {instruction.Arguments}"); + + layout = new DockerImageLayout(from[0]); + if (stageNames.TryGetValue(from[0], out var parent)) + layout.CopyStage(parent); + stages.Add(layout); + if (from.Count == 3) + stageNames[from[2]] = layout; + workingDirectory = "/"; + continue; + } + + if (layout == null) + { + if (instruction.Name == "ARG") + continue; + throw new InvalidDataException($"Docker {instruction.Name} comes before any FROM."); + } + switch (instruction.Name) { case "WORKDIR": @@ -228,7 +266,11 @@ internal static DockerImageLayout Create(string repositoryRoot) repositoryRoot, workingDirectory, instruction.Arguments, - dockerIgnore); + dockerIgnore, + stageNames); + break; + case "USER": + layout.User = instruction.Arguments; break; case "RUN": var build = DotNetBuildCommand.Parse(instruction.Arguments); @@ -241,7 +283,10 @@ internal static DockerImageLayout Create(string repositoryRoot) } } - return layout; + if (layout == null) + throw new InvalidDataException("Dockerfile has no FROM instruction."); + + return stages; } internal bool ContainsFile(string path) => @@ -262,12 +307,35 @@ private void ApplyCopy( string repositoryRoot, string workingDirectory, string arguments, - DockerIgnoreMatcher dockerIgnore) - { - var fields = SplitArguments(arguments); + DockerIgnoreMatcher dockerIgnore, + IReadOnlyDictionary stages) + { + // --chown and --chmod set ownership and modes, which the layout doesn't model. + // --from copies from an earlier stage instead of the build context. + var fields = SplitArguments(arguments).ToList(); + string fromStage = null; + while (fields.Count != 0 && fields[0].StartsWith("--", StringComparison.Ordinal)) + { + const string fromPrefix = "--from="; + if (fields[0].StartsWith(fromPrefix, StringComparison.Ordinal)) + fromStage = fields[0].Substring(fromPrefix.Length); + else if (!fields[0].StartsWith("--chown=", StringComparison.Ordinal) && + !fields[0].StartsWith("--chmod=", StringComparison.Ordinal)) + throw new InvalidDataException($"Unsupported Docker COPY instruction: {arguments}"); + fields.RemoveAt(0); + } + if (fields.Count != 2) throw new InvalidDataException($"Unsupported Docker COPY instruction: {arguments}"); + if (fromStage != null) + { + if (!stages.TryGetValue(fromStage, out var stage)) + throw new InvalidDataException($"Docker COPY --from names no earlier stage: {arguments}"); + ApplyStageCopy(stage, workingDirectory, fields[0], fields[1]); + return; + } + var source = Path.GetFullPath(Path.Combine( repositoryRoot, fields[0].Replace('/', Path.DirectorySeparatorChar))); @@ -306,6 +374,62 @@ private void ApplyCopy( } } + private void ApplyStageCopy( + DockerImageLayout stage, + string workingDirectory, + string sourcePath, + string destinationPath) + { + var source = PosixPath.Resolve("/", sourcePath); + var destination = PosixPath.Resolve(workingDirectory, destinationPath); + if (stage._files.Contains(source)) + { + if (destinationPath.EndsWith("/", StringComparison.Ordinal) || + _directories.Contains(destination)) + destination = PosixPath.Resolve( + destination, + source.Substring(source.LastIndexOf('/') + 1)); + CopyStageFile(stage, source, destination); + return; + } + + if (!stage._directories.Contains(source)) + throw new InvalidDataException( + $"Docker COPY --from source '{sourcePath}' does not exist in that stage."); + + AddDirectory(destination); + var prefix = source.TrimEnd('/') + "/"; + foreach (var file in stage._files + .Where(path => path.StartsWith(prefix, StringComparison.Ordinal)) + .ToArray()) + CopyStageFile( + stage, + file, + PosixPath.Resolve(destination, file.Substring(prefix.Length))); + } + + private void CopyStageFile( + DockerImageLayout stage, + string source, + string destination) + { + if (stage._copiedHostFiles.TryGetValue(source, out var hostPath)) + AddCopiedFile(hostPath, destination); + else + AddFile(destination); + } + + private void CopyStage(DockerImageLayout parent) + { + _files.UnionWith(parent._files); + _directories.UnionWith(parent._directories); + foreach (var pair in parent._copiedFiles) + _copiedFiles[pair.Key] = pair.Value; + foreach (var pair in parent._copiedHostFiles) + _copiedHostFiles[pair.Key] = pair.Value; + User = parent.User; + } + private void ApplyBuild( string workingDirectory, DotNetBuildCommand build) diff --git a/src/Rasa.Test/Compatibility/ContainerLayoutModelsTests.cs b/src/Rasa.Test/Compatibility/ContainerLayoutModelsTests.cs index e10a22be..030893c7 100644 --- a/src/Rasa.Test/Compatibility/ContainerLayoutModelsTests.cs +++ b/src/Rasa.Test/Compatibility/ContainerLayoutModelsTests.cs @@ -131,6 +131,99 @@ RUN dotnet build src/App/App.csproj src/Decoy/Decoy.csproj -c Release () => DockerImageLayout.Create(repository.Root)); } + [TestMethod] + public void FinalStageHoldsOnlyWhatItCopiesFromEarlierStages() + { + using var repository = new DockerRepositoryFixture(); + repository.Write(".dockerignore", "**/bin\n**/obj\n"); + repository.Write("Dockerfile", """ + FROM mcr.microsoft.com/dotnet/sdk:10.0.401 AS build + WORKDIR /app + COPY src /app/src + RUN dotnet build src/App/App.csproj -c Release + FROM mcr.microsoft.com/dotnet/runtime:10.0 AS runtime + WORKDIR /app + COPY --from=build --chown=app:app /app/src/App/bin/Release/net10.0 /app/src/App/bin/Release/net10.0 + COPY --chown=app:app assets /app/src/App/bin/Release/net10.0/assets + USER app + """); + repository.Write("src/App/App.csproj", Project( + "App", + @"..\Shared\Shared.csproj", + "appsettings.json")); + repository.Write("src/App/appsettings.json", "{}"); + repository.Write("src/Shared/Shared.csproj", Project("Shared")); + repository.Write("assets/map.nav", "mesh"); + + var stages = DockerImageLayout.CreateStages(repository.Root); + var image = DockerImageLayout.Create(repository.Root); + + Assert.AreEqual(2, stages.Count); + Assert.AreEqual("mcr.microsoft.com/dotnet/runtime:10.0", image.BaseImage); + Assert.AreEqual("app", image.User); + Assert.AreEqual("root", stages[0].User); + Assert.AreEqual("mcr.microsoft.com/dotnet/sdk:10.0.401", stages[0].BaseImage); + Assert.IsTrue(image.ContainsFile("/app/src/App/bin/Release/net10.0/App.dll")); + Assert.IsTrue(image.ContainsFile("/app/src/App/bin/Release/net10.0/Shared.dll")); + Assert.IsTrue(image.ContainsFile("/app/src/App/bin/Release/net10.0/appsettings.json")); + Assert.IsTrue(image.ContainsFile("/app/src/App/bin/Release/net10.0/assets/map.nav")); + Assert.IsFalse(image.ContainsFile("/app/src/App/App.csproj")); + Assert.IsFalse(image.ContainsFile("/app/src/Shared/bin/Release/net10.0/Shared.dll")); + Assert.IsTrue(stages[0].ContainsFile("/app/src/App/App.csproj")); + } + + [TestMethod] + public void CopyFromAnUnknownStageIsRejected() + { + using var repository = new DockerRepositoryFixture(); + repository.Write(".dockerignore", string.Empty); + repository.Write("Dockerfile", """ + FROM mcr.microsoft.com/dotnet/runtime:10.0 + COPY --from=build /app /app + """); + + Assert.ThrowsExactly( + () => DockerImageLayout.Create(repository.Root)); + } + + [TestMethod] + public void CopyOfAPathMissingFromTheStageIsRejected() + { + using var repository = new DockerRepositoryFixture(); + repository.Write(".dockerignore", string.Empty); + repository.Write("Dockerfile", """ + FROM scratch AS build + WORKDIR /app + FROM mcr.microsoft.com/dotnet/runtime:10.0 + COPY --from=build /app/missing /app + """); + + Assert.ThrowsExactly( + () => DockerImageLayout.Create(repository.Root)); + } + + [TestMethod] + [DataRow("COPY --link src /app/src")] + [DataRow("COPY --parents src /app/src")] + [DataRow("COPY src other /app/")] + [DataRow("COPY --chown=app:app src other /app/")] + public void UnsupportedCopyFormsAreStillRejected(string copy) + { + using var repository = new DockerRepositoryFixture(); + repository.Write(".dockerignore", string.Empty); + repository.Write("Dockerfile", $""" + FROM scratch + WORKDIR /app + {copy} + """); + repository.Write("src/App/App.csproj", Project("App")); + repository.Write("other/file.txt", "x"); + + var exception = Assert.ThrowsExactly( + () => DockerImageLayout.Create(repository.Root)); + StringAssert.StartsWith(exception.Message, "Unsupported Docker COPY instruction"); + } + private static string Project( string assemblyName, string projectReference = null, diff --git a/src/Rasa.Test/Compatibility/PlatformCompatibilityTests.cs b/src/Rasa.Test/Compatibility/PlatformCompatibilityTests.cs index f07fc093..cd4ba6c2 100644 --- a/src/Rasa.Test/Compatibility/PlatformCompatibilityTests.cs +++ b/src/Rasa.Test/Compatibility/PlatformCompatibilityTests.cs @@ -12,15 +12,20 @@ namespace Rasa.Test.Compatibility public class PlatformCompatibilityTests { [TestMethod] - public void RepositoryPinsVerifiedDotNetSdk() + public void RepositoryPinsOneExactDotNetSdk() { var repositoryRoot = FindRepositoryRoot(); - using var document = JsonDocument.Parse(File.ReadAllText(Path.Combine(repositoryRoot, "global.json"))); + var globalJson = RepositoryPins.Read(repositoryRoot, "global.json"); + using var document = JsonDocument.Parse(globalJson); var sdk = document.RootElement.GetProperty("sdk"); - Assert.AreEqual("10.0.401", sdk.GetProperty("version").GetString()); Assert.AreEqual("disable", sdk.GetProperty("rollForward").GetString()); Assert.IsFalse(sdk.GetProperty("allowPrerelease").GetBoolean()); + Assert.AreEqual( + string.Empty, + string.Join(Environment.NewLine, RepositoryPins.CheckSdk( + globalJson, + RepositoryPins.Read(repositoryRoot, "Dockerfile")))); } [TestMethod] @@ -61,8 +66,29 @@ public void NavigationProjectsKeepPr95Dependencies() var navMesh = XDocument.Load(Path.Combine(repositoryRoot, "src", "Rasa.NavMesh", "Rasa.NavMesh.csproj")); CollectionAssert.Contains(ReadProjectReferences(game).ToArray(), @"..\Rasa.Navigation\Rasa.Navigation.csproj"); - Assert.AreEqual("2026.3.1", ReadPackageVersion(navigation, "DotRecast.Detour")); - Assert.AreEqual("2026.3.1", ReadPackageVersion(navMesh, "DotRecast.Recast")); + CollectionAssert.Contains(ReadPackageReferences(navigation).ToArray(), "DotRecast.Detour"); + CollectionAssert.Contains(ReadPackageReferences(navMesh).ToArray(), "DotRecast.Recast"); + } + + [TestMethod] + public void PackagesThatMoveTogetherShareOneVersion() + { + var repositoryRoot = FindRepositoryRoot(); + + Assert.AreEqual( + string.Empty, + string.Join(Environment.NewLine, RepositoryPins.CheckPackages( + RepositoryPins.Read(repositoryRoot, "Directory.Packages.props"), + RepositoryPins.Read(repositoryRoot, ".config", "dotnet-tools.json")))); + } + + [TestMethod] + public void DockerImageRunsAsTheRuntimeImagesNonRootUser() + { + var image = DockerImageLayout.Create(FindRepositoryRoot()); + + Assert.AreEqual("app", image.User); + StringAssert.StartsWith(image.BaseImage, "mcr.microsoft.com/dotnet/runtime:"); } [TestMethod] @@ -110,11 +136,11 @@ private static IEnumerable ReadProjectReferences(XDocument project) .Where(reference => reference != null); } - private static string ReadPackageVersion(XDocument project, string package) + private static IEnumerable ReadPackageReferences(XDocument project) { return project.Descendants("PackageReference") - .Single(reference => reference.Attribute("Include")?.Value == package) - .Attribute("Version")?.Value; + .Select(reference => reference.Attribute("Include")?.Value) + .Where(reference => reference != null); } private static void AssertServiceLayout( diff --git a/src/Rasa.Test/Compatibility/RepositoryPins.cs b/src/Rasa.Test/Compatibility/RepositoryPins.cs new file mode 100644 index 00000000..e207d5fb --- /dev/null +++ b/src/Rasa.Test/Compatibility/RepositoryPins.cs @@ -0,0 +1,106 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Xml.Linq; + +namespace Rasa.Test.Compatibility +{ + // Version pins that live in more than one file. The guards check that the copies agree rather + // than that they hold particular versions, so an update (by hand or by Dependabot) that moves + // every copy together passes, and one that misses a copy fails. + internal static class RepositoryPins + { + private const string SdkImage = "mcr.microsoft.com/dotnet/sdk"; + private const string RuntimeImage = "mcr.microsoft.com/dotnet/runtime"; + + internal static string ReadGlobalJsonSdk(string globalJson) + { + using var document = JsonDocument.Parse(globalJson); + return document.RootElement.GetProperty("sdk").GetProperty("version").GetString(); + } + + internal static IReadOnlyList ReadDockerfileImages(string dockerfile) + { + return DockerfileModel.Parse(dockerfile).Instructions + .Where(instruction => instruction.Name == "FROM") + .Select(instruction => instruction.Arguments.Split(' ', StringSplitOptions.RemoveEmptyEntries)[0]) + .ToArray(); + } + + // Every SDK stage builds with the SDK global.json pins, and the image runs on that SDK's + // runtime line. Returns the disagreements; empty when the files agree. + internal static IReadOnlyList CheckSdk(string globalJson, string dockerfile) + { + var problems = new List(); + var sdk = ReadGlobalJsonSdk(globalJson); + var images = ReadDockerfileImages(dockerfile); + var sdkTags = images + .Where(image => image.StartsWith(SdkImage + ":", StringComparison.Ordinal)) + .Select(image => image.Substring(SdkImage.Length + 1)) + .ToArray(); + + if (sdkTags.Length == 0) + problems.Add($"The Dockerfile has no {SdkImage} stage."); + foreach (var tag in sdkTags.Where(tag => tag != sdk)) + problems.Add($"The Dockerfile builds with {SdkImage}:{tag}, but global.json pins {sdk}."); + + var final = images.LastOrDefault() ?? string.Empty; + var sdkLine = string.Join('.', sdk.Split('.').Take(2)); + if (final != $"{RuntimeImage}:{sdkLine}") + problems.Add($"The Dockerfile's final stage is {final}, not {RuntimeImage}:{sdkLine}."); + + return problems; + } + + // Packages that must move together share one version in Directory.Packages.props, and the + // dotnet-ef tool matches the EF Core packages it migrates. + internal static IReadOnlyList CheckPackages(string packagesProps, string toolManifest) + { + var problems = new List(); + var versions = XDocument.Parse(packagesProps).Descendants("PackageVersion") + .ToDictionary( + item => item.Attribute("Include").Value, + item => item.Attribute("Version").Value, + StringComparer.OrdinalIgnoreCase); + + RequireSameVersion(versions, problems, "DotRecast.Detour", "DotRecast.Recast"); + RequireSameVersion( + versions, + problems, + "Microsoft.EntityFrameworkCore", + "Microsoft.EntityFrameworkCore.Design", + "Microsoft.EntityFrameworkCore.Sqlite"); + RequireSameVersion(versions, problems, "MSTest.TestAdapter", "MSTest.TestFramework"); + + using var tools = JsonDocument.Parse(toolManifest); + var dotnetEf = tools.RootElement.GetProperty("tools").GetProperty("dotnet-ef") + .GetProperty("version").GetString(); + if (versions.TryGetValue("Microsoft.EntityFrameworkCore", out var efCore) && dotnetEf != efCore) + problems.Add($"dotnet-ef is {dotnetEf} in .config/dotnet-tools.json, but EF Core is {efCore}."); + + return problems; + } + + private static void RequireSameVersion( + IReadOnlyDictionary versions, + List problems, + params string[] packages) + { + var missing = packages.Where(package => !versions.ContainsKey(package)).ToArray(); + if (missing.Length != 0) + { + problems.Add($"Directory.Packages.props has no version for {string.Join(", ", missing)}."); + return; + } + + if (packages.Select(package => versions[package]).Distinct().Count() > 1) + problems.Add("These packages must share one version: " + + string.Join(", ", packages.Select(package => $"{package} {versions[package]}")) + "."); + } + + internal static string Read(string repositoryRoot, params string[] path) => + File.ReadAllText(Path.Combine(new[] { repositoryRoot }.Concat(path).ToArray())); + } +} diff --git a/src/Rasa.Test/Compatibility/RepositoryPinsTests.cs b/src/Rasa.Test/Compatibility/RepositoryPinsTests.cs new file mode 100644 index 00000000..0b472b7f --- /dev/null +++ b/src/Rasa.Test/Compatibility/RepositoryPinsTests.cs @@ -0,0 +1,120 @@ +using System.Linq; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Rasa.Test.Compatibility +{ + [TestClass] + public class RepositoryPinsTests + { + private const string GlobalJson = """ + { "sdk": { "version": "10.0.401", "rollForward": "disable", "allowPrerelease": false } } + """; + + private const string ToolManifest = """ + { "version": 1, "isRoot": true, "tools": { "dotnet-ef": { "version": "9.0.20", "commands": [ "dotnet-ef" ] } } } + """; + + [TestMethod] + public void SdkCheckAcceptsAMultiStageDockerfileOnTheGlobalJsonSdk() + { + Assert.AreEqual(0, RepositoryPins.CheckSdk(GlobalJson, Dockerfile("10.0.401", "10.0")).Count); + } + + [TestMethod] + public void SdkCheckRejectsADockerfileBuildingWithAnotherSdk() + { + var problems = RepositoryPins.CheckSdk(GlobalJson, Dockerfile("10.0.402", "10.0")); + + Assert.AreEqual(1, problems.Count); + StringAssert.Contains(problems[0], "sdk:10.0.402"); + } + + [TestMethod] + public void SdkCheckRejectsARuntimeFromAnotherLine() + { + var problems = RepositoryPins.CheckSdk(GlobalJson, Dockerfile("10.0.401", "11.0")); + + Assert.AreEqual(1, problems.Count); + StringAssert.Contains(problems[0], "runtime:11.0"); + } + + [TestMethod] + public void SdkCheckRejectsAnImageThatShipsTheSdk() + { + var problems = RepositoryPins.CheckSdk( + GlobalJson, + "FROM mcr.microsoft.com/dotnet/sdk:10.0.401\nRUN dotnet build\n"); + + Assert.AreEqual(1, problems.Count); + StringAssert.Contains(problems[0], "final stage"); + } + + [TestMethod] + public void PackageCheckAcceptsMatchingVersions() + { + Assert.AreEqual(0, RepositoryPins.CheckPackages(Packages(), ToolManifest).Count); + } + + [TestMethod] + public void PackageCheckRejectsDotRecastPackagesThatDisagree() + { + var problems = RepositoryPins.CheckPackages(Packages(recast: "2026.4.0"), ToolManifest); + + Assert.AreEqual(1, problems.Count); + StringAssert.Contains(problems[0], "DotRecast.Recast 2026.4.0"); + } + + [TestMethod] + public void PackageCheckRejectsEfCorePackagesThatDisagree() + { + var problems = RepositoryPins.CheckPackages(Packages(efSqlite: "9.0.21"), ToolManifest); + + Assert.AreEqual(1, problems.Count); + StringAssert.Contains(problems[0], "Microsoft.EntityFrameworkCore.Sqlite 9.0.21"); + } + + [TestMethod] + public void PackageCheckRejectsADotnetEfToolOnAnotherVersion() + { + var problems = RepositoryPins.CheckPackages( + Packages(), + ToolManifest.Replace("9.0.20", "9.0.21")); + + Assert.AreEqual(1, problems.Count); + StringAssert.Contains(problems[0], "dotnet-ef is 9.0.21"); + } + + [TestMethod] + public void PackageCheckRejectsAMissingPackage() + { + var problems = RepositoryPins.CheckPackages( + string.Join('\n', Packages().Split('\n').Where(line => !line.Contains("DotRecast.Detour"))), + ToolManifest); + + Assert.AreEqual(1, problems.Count); + StringAssert.Contains(problems[0], "no version for DotRecast.Detour"); + } + + private static string Dockerfile(string sdk, string runtime) => $""" + FROM mcr.microsoft.com/dotnet/sdk:{sdk} AS build + RUN dotnet build --configuration Release + FROM mcr.microsoft.com/dotnet/runtime:{runtime} AS runtime + COPY --from=build /app /app + USER app + """; + + private static string Packages(string recast = "2026.3.1", string efSqlite = "9.0.20") => $""" + + + + + + + + + + + + """; + } +} diff --git a/src/Rasa.Test/Rasa.Test.csproj b/src/Rasa.Test/Rasa.Test.csproj index 307220d2..43363837 100644 --- a/src/Rasa.Test/Rasa.Test.csproj +++ b/src/Rasa.Test/Rasa.Test.csproj @@ -9,10 +9,10 @@ - - - - + + + + all diff --git a/src/Rasa.Utils/Rasa.Utils.csproj b/src/Rasa.Utils/Rasa.Utils.csproj index 75c3c860..4ef6515f 100644 --- a/src/Rasa.Utils/Rasa.Utils.csproj +++ b/src/Rasa.Utils/Rasa.Utils.csproj @@ -13,10 +13,10 @@ - - - - + + + + From 231c0ab93151ae2b684ba63b02ccbe28fadf9816 Mon Sep 17 00:00:00 2001 From: Steven Cady Date: Mon, 5 Oct 2026 08:29:47 -0500 Subject: [PATCH 02/10] Fix the build warnings the CI annotations flag - MSTEST0017: NonContiguousMemoryStreamTests passes expected before actual, and asserts the counts its Read calls return (CA2022). - CA2022: PythonWriter.ToString reads its buffer with ReadExactly. - CS0414/CS0219: drop MapChannelManager.MapChannel_PlayerQueue and three unused bonus locals in ManifestationManager. - EF1002: MigrationConsolidationTests and ClientWaypointIdTests use the parameterized SqlQuery/ExecuteSql; BootcampWorldContentTests keeps a raw query for table names (identifiers can't be parameters), with a scoped suppression. - EF1001: MySqlMigrationHistoryRepository keeps subclassing Pomelo's internal MySqlHistoryRepository, the only way to bound GET_LOCK names to 64 characters, now behind a scoped, explained suppression. - MSTEST0044: [DataTestMethod] becomes [TestMethod]. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AxABTHjs6nJrsXVCTnmMo7 --- .../MySqlMigrationHistoryRepository.cs | 9 +++++ .../Managers/ManifestationManager.cs | 4 -- src/Rasa.Game/Managers/MapChannelManager.cs | 1 - src/Rasa.Game/Memory/PythonWriter.cs | 2 +- .../Database/MigrationConsolidationTests.cs | 4 +- .../Memory/NonContiguousMemoryStreamTests.cs | 38 +++++++++---------- .../Missions/BootcampWorldContentTests.cs | 3 ++ .../Missions/MissionEquipmentProgressTests.cs | 2 +- .../MissionProgressRuleAuthoringTests.cs | 2 +- src/Rasa.Test/World/ClientWaypointIdTests.cs | 2 +- 10 files changed, 37 insertions(+), 30 deletions(-) diff --git a/src/Rasa.DBL/Services/DbContext/MySqlMigrationHistoryRepository.cs b/src/Rasa.DBL/Services/DbContext/MySqlMigrationHistoryRepository.cs index 4942ba55..7123fd45 100644 --- a/src/Rasa.DBL/Services/DbContext/MySqlMigrationHistoryRepository.cs +++ b/src/Rasa.DBL/Services/DbContext/MySqlMigrationHistoryRepository.cs @@ -6,6 +6,14 @@ namespace Rasa.Services.DbContext { + // MySQL's GET_LOCK refuses names over 64 characters, and Pomelo builds the migration lock name + // from the database name. The lock name can only be changed by overriding GetDatabaseLockName + // on Pomelo's internal MySqlHistoryRepository: there's no option for it, and implementing + // IHistoryRepository on the public HistoryRepository base would mean copying Pomelo's locking + // and history-table SQL. So this deliberately uses the internal API (EF1001). A Pomelo upgrade + // that changes it breaks the build here, and MySqlPlatformCompatibilityTests checks both the + // registration and the lock names. +#pragma warning disable EF1001 internal sealed class MySqlMigrationHistoryRepository : MySqlHistoryRepository { public MySqlMigrationHistoryRepository(HistoryRepositoryDependencies dependencies) @@ -17,6 +25,7 @@ protected override string GetDatabaseLockName(string databaseName) { return BoundLockName(base.GetDatabaseLockName(databaseName)); } +#pragma warning restore EF1001 internal static string BoundLockName(string name) { diff --git a/src/Rasa.Game/Managers/ManifestationManager.cs b/src/Rasa.Game/Managers/ManifestationManager.cs index 528eecae..574d3863 100644 --- a/src/Rasa.Game/Managers/ManifestationManager.cs +++ b/src/Rasa.Game/Managers/ManifestationManager.cs @@ -4279,10 +4279,6 @@ public void UpdateStatsValues(Client client, bool fullreset) var mindBonus = 0; var spiritBonus = 0; - var healthBonus = 0; - var chiBonus = 0; - var regenBonus = 0; - float armorBonusPercent = (float)Math.Max(0.0, (totalBody - (2 * (level - 1) + 10)) * 0.667); // every body attribute over the default base attribute gives 0.667% bonus armo; float logosBonusPercent = (float)Math.Max(0.0, (totalMind - (2 * (level - 1) + 10)) * 0.375); // every mind attribute over the default base attribute gives 0.375% bonus logos damage float critBonusPercent = (float)Math.Max(0.0, (totalSpirit - (2 * (level - 1) + 10)) * 0.065); // every spirit attribute over the default base attribute gives 0.065% bonus crit chance; diff --git a/src/Rasa.Game/Managers/MapChannelManager.cs b/src/Rasa.Game/Managers/MapChannelManager.cs index d1ca1927..85a0b853 100644 --- a/src/Rasa.Game/Managers/MapChannelManager.cs +++ b/src/Rasa.Game/Managers/MapChannelManager.cs @@ -21,7 +21,6 @@ public partial class MapChannelManager { private static MapChannelManager _instance; private static readonly object InstanceLock = new object(); - private readonly int MapChannel_PlayerQueue = 32; public readonly Dictionary MapChannelArray = new Dictionary(); // list of loaded maps public readonly Timer Timer = new(); diff --git a/src/Rasa.Game/Memory/PythonWriter.cs b/src/Rasa.Game/Memory/PythonWriter.cs index ac067292..3ca50d68 100644 --- a/src/Rasa.Game/Memory/PythonWriter.cs +++ b/src/Rasa.Game/Memory/PythonWriter.cs @@ -287,7 +287,7 @@ public override string ToString() var currentPosition = Writer.BaseStream.Position; Writer.BaseStream.Position = BeginPositon; - Writer.BaseStream.Read(data, 0, data.Length); + Writer.BaseStream.ReadExactly(data); Writer.BaseStream.Position = currentPosition; using var pr = new PythonReader(new BinaryReader(new MemoryStream(data))); diff --git a/src/Rasa.Test/Database/MigrationConsolidationTests.cs b/src/Rasa.Test/Database/MigrationConsolidationTests.cs index ec67b5f9..10166747 100644 --- a/src/Rasa.Test/Database/MigrationConsolidationTests.cs +++ b/src/Rasa.Test/Database/MigrationConsolidationTests.cs @@ -101,8 +101,8 @@ public void SqliteCharacterSchemaPreservesMissionColumnDefaults() ("mission_scene", "assignment_id", "''"), ("mission_timer", "sequence_id", "0") }) - Assert.AreEqual(value, database.Database.SqlQueryRaw( - $"SELECT dflt_value AS Value FROM pragma_table_info('{table}') WHERE name = '{column}'").Single(), + Assert.AreEqual(value, database.Database.SqlQuery( + $"SELECT dflt_value AS Value FROM pragma_table_info({table}) WHERE name = {column}").Single(), $"{table}.{column}"); } diff --git a/src/Rasa.Test/Memory/NonContiguousMemoryStreamTests.cs b/src/Rasa.Test/Memory/NonContiguousMemoryStreamTests.cs index 3b90ba87..6a0f82f4 100644 --- a/src/Rasa.Test/Memory/NonContiguousMemoryStreamTests.cs +++ b/src/Rasa.Test/Memory/NonContiguousMemoryStreamTests.cs @@ -78,9 +78,9 @@ public void TestRead() var read3 = new byte[70]; var readCount3 = stream.Read(read3, 0, read3.Length); - Assert.AreEqual(readCount1, 30); - Assert.AreEqual(readCount2, 20); - Assert.AreEqual(readCount3, 70); + Assert.AreEqual(30, readCount1); + Assert.AreEqual(20, readCount2); + Assert.AreEqual(70, readCount3); Assert.AreEqual(stream.Position, stream.Length); // Validate read1 @@ -130,20 +130,20 @@ public void TestRemoveBytes() stream.CopyFromArray(buffer2); var throwAwayData = new byte[2]; - stream.Read(throwAwayData, 0, 2); + Assert.AreEqual(2, stream.Read(throwAwayData, 0, 2)); stream.RemoveBytes(3); var data = new byte[7]; - stream.Read(data, 0, data.Length); - - Assert.AreEqual(data[0], 3); - Assert.AreEqual(data[1], 4); - Assert.AreEqual(data[2], 0); - Assert.AreEqual(data[3], 2); - Assert.AreEqual(data[4], 4); - Assert.AreEqual(data[5], 6); - Assert.AreEqual(data[6], 8); + Assert.AreEqual(data.Length, stream.Read(data, 0, data.Length)); + + Assert.AreEqual(3, data[0]); + Assert.AreEqual(4, data[1]); + Assert.AreEqual(0, data[2]); + Assert.AreEqual(2, data[3]); + Assert.AreEqual(4, data[4]); + Assert.AreEqual(6, data[5]); + Assert.AreEqual(8, data[6]); Assert.AreEqual(stream.Length, buffer1.Length + buffer2.Length - 3); Assert.AreEqual(stream.Position, stream.Length); } @@ -166,12 +166,12 @@ public void TestRemoveMoreBytes() stream.CopyFromArray(buffer2); var throwAwayData = new byte[6]; - stream.Read(throwAwayData, 0, 6); + Assert.AreEqual(6, stream.Read(throwAwayData, 0, 6)); stream.RemoveBytes(9); - Assert.AreEqual(stream.Length, 1); - Assert.AreEqual(stream.Position, 0); + Assert.AreEqual(1, stream.Length); + Assert.AreEqual(0, stream.Position); } [TestMethod] @@ -192,12 +192,12 @@ public void TestRemoveAllBytes() stream.CopyFromArray(buffer2); var throwAwayData = new byte[6]; - stream.Read(throwAwayData, 0, 6); + Assert.AreEqual(6, stream.Read(throwAwayData, 0, 6)); stream.RemoveBytes(10); - Assert.AreEqual(stream.Length, 0); - Assert.AreEqual(stream.Position, 0); + Assert.AreEqual(0, stream.Length); + Assert.AreEqual(0, stream.Position); } [TestMethod] diff --git a/src/Rasa.Test/Missions/BootcampWorldContentTests.cs b/src/Rasa.Test/Missions/BootcampWorldContentTests.cs index 522be3c3..324e0066 100644 --- a/src/Rasa.Test/Missions/BootcampWorldContentTests.cs +++ b/src/Rasa.Test/Missions/BootcampWorldContentTests.cs @@ -813,9 +813,12 @@ public void ConsolidatedWorldContentPreservesEveryBootcampTableRowCount() ["mission_scenario_step"] = 52, ["mission_evidence"] = 17, ["mission_scene_binding"] = 5, ["mission_channel_policy"] = 1, ["mission_repeat_policy"] = 0 }; + // A table name can't be a SQL parameter; these come from the literal list above. +#pragma warning disable EF1002 foreach (var (table, count) in expected) Assert.AreEqual(count, context.Database.SqlQueryRaw( $"SELECT COUNT(*) AS Value FROM {table} WHERE content_revision = 'deployment_11'").Single(), table); +#pragma warning restore EF1002 Assert.AreEqual(1, context.Set().Count()); }); } diff --git a/src/Rasa.Test/Missions/MissionEquipmentProgressTests.cs b/src/Rasa.Test/Missions/MissionEquipmentProgressTests.cs index b301628e..78c2d8e4 100644 --- a/src/Rasa.Test/Missions/MissionEquipmentProgressTests.cs +++ b/src/Rasa.Test/Missions/MissionEquipmentProgressTests.cs @@ -18,7 +18,7 @@ namespace Rasa.Test.Missions [DoNotParallelize] public class MissionEquipmentProgressTests { - [DataTestMethod] + [TestMethod] [DataRow(false)] [DataRow(true)] public void CommittedEquipCompletesConfiguredEquipmentObjective(bool matchTemplateId) diff --git a/src/Rasa.Test/Missions/MissionProgressRuleAuthoringTests.cs b/src/Rasa.Test/Missions/MissionProgressRuleAuthoringTests.cs index 9f4c237f..5c903832 100644 --- a/src/Rasa.Test/Missions/MissionProgressRuleAuthoringTests.cs +++ b/src/Rasa.Test/Missions/MissionProgressRuleAuthoringTests.cs @@ -163,7 +163,7 @@ public void ItemCounterRuleAcceptsUIntBoundaryRange() Assert.IsNull(diagnostic); } - [DataTestMethod] + [TestMethod] [DataRow(false, 7000U)] [DataRow(true, 2800U)] public void ItemEquippedRuleAcceptsClassOrTemplateSelection( diff --git a/src/Rasa.Test/World/ClientWaypointIdTests.cs b/src/Rasa.Test/World/ClientWaypointIdTests.cs index cea4ed7c..31a7a080 100644 --- a/src/Rasa.Test/World/ClientWaypointIdTests.cs +++ b/src/Rasa.Test/World/ClientWaypointIdTests.cs @@ -171,7 +171,7 @@ public void TheCharacterMigrationMovesWhatEachCharacterHasGained() (2, 575, 2), (2, 622, 2), (2, 583, 2), (2, 135, 2), (3, 582, 2), (3, 607, 2), (3, 534, 2), (3, 624, 2), (3, 541, 2), (3, 576, 2), (3, 613, 5), (3, 583, 2), (3, 57, 2) }) - context.Database.ExecuteSqlRaw($"insert into character_teleporter (character_id, waypointId, waypoint_type) values ({character}, {waypoint}, {type});"); + context.Database.ExecuteSql($"insert into character_teleporter (character_id, waypointId, waypoint_type) values ({character}, {waypoint}, {type});"); migrator.Migrate(); From 7eb8b9a49b3895e55d5bcb7f8e533751291a1512 Mon Sep 17 00:00:00 2001 From: Steven Cady Date: Mon, 5 Oct 2026 08:37:56 -0500 Subject: [PATCH 03/10] Add migration drift, container smoke, release and Dependabot workflows - dotnet.yml: a `migration drift` job runs has-pending-model-changes for all six EF contexts, and `tests complete` requires it. MySqlAuthContext's design-time factory now uses a fixed server version like Char and World, so none of the six needs a database server. - container.yml: builds the image, starts docker-compose.yml, and checks from inside each container that every TCP port compose maps is listening (a host-side probe always connects through Docker's proxy); UDP ports by their startup log line. Also checks the image runs as UID 1654, and runs an advisory Trivy scan pinned by commit. - release-on-comment.yml / release-container.yml: a /release comment on a merged PR from someone with write access dispatches a build of development's HEAD to GHCR, once CI has passed on that commit. - prune-container.yml: weekly, keeps the 14 newest image versions. - dependabot.yml: NuGet (grouped; EF Core 10 and Pomelo held back), GitHub Actions, and the SDK, whose global.json and Dockerfile bumps share one multi-ecosystem PR. - docs/setup.md: six drift commands, and how to bump the SDK or packages. Actions in workflows that push images or hold write tokens are pinned by commit SHA. Refs #132 (CI-1, CI-4, CI-5, CI-8, CI-9). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AxABTHjs6nJrsXVCTnmMo7 --- .github/dependabot.yml | 63 +++++++++++ .github/workflows/container.yml | 103 ++++++++++++++++++ .github/workflows/dotnet.yml | 44 +++++++- .github/workflows/prune-container.yml | 41 +++++++ .github/workflows/release-container.yml | 89 +++++++++++++++ .github/workflows/release-on-comment.yml | 58 ++++++++++ docs/setup.md | 30 +++-- .../Auth/DesignTimeMySqlAuthContextFactory.cs | 25 ++++- 8 files changed, 439 insertions(+), 14 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/container.yml create mode 100644 .github/workflows/prune-container.yml create mode 100644 .github/workflows/release-container.yml create mode 100644 .github/workflows/release-on-comment.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..242d4ce6 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,63 @@ +# Version updates. Versions that live in more than one file are guarded by +# PlatformCompatibilityTests, so each group below is shaped to move every copy in one PR. +version: 2 + +# A new SDK changes global.json (dotnet-sdk) and the Dockerfile's sdk stage (docker) together, +# in one PR; separately, each would fail the guard that they agree. +multi-ecosystem-groups: + dotnet-sdk: + schedule: + interval: monthly + +updates: + - package-ecosystem: dotnet-sdk + directory: / + multi-ecosystem-group: dotnet-sdk + patterns: [ "*" ] + ignore: + # Dependabot ignores global.json's rollForward, so a new major is held back here. + - dependency-name: "*" + update-types: [ "version-update:semver-major" ] + + - package-ecosystem: docker + directory: / + multi-ecosystem-group: dotnet-sdk + patterns: [ "dotnet/*" ] + ignore: + - dependency-name: "dotnet/*" + versions: [ ">= 11" ] + + # Every NuGet version is in Directory.Packages.props, and dotnet-ef in .config/dotnet-tools.json. + - package-ecosystem: nuget + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 5 + groups: + ef-core: + patterns: [ "Microsoft.EntityFrameworkCore*", "Pomelo.EntityFrameworkCore.MySql", "dotnet-ef" ] + mstest: + patterns: [ "MSTest.*", "Microsoft.NET.Test.Sdk", "coverlet.*" ] + dotrecast: + patterns: [ "DotRecast.*" ] + other: + patterns: [ "*" ] + ignore: + # Pomelo 9.0.0 supports EF Core 9 only. Moving to EF Core 10 needs a MySQL provider + # decision of its own; Dependabot shouldn't make it. + - dependency-name: "Microsoft.EntityFrameworkCore*" + versions: [ ">= 10" ] + - dependency-name: "dotnet-ef" + versions: [ ">= 10" ] + - dependency-name: "Pomelo.EntityFrameworkCore.MySql" + versions: [ ">= 10" ] + + # Workflows that hold secrets or push images pin actions by commit; this keeps those pins and + # the major tags in the other workflows current. + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + groups: + actions: + patterns: [ "*" ] diff --git a/.github/workflows/container.yml b/.github/workflows/container.yml new file mode 100644 index 00000000..58b0ac69 --- /dev/null +++ b/.github/workflows/container.yml @@ -0,0 +1,103 @@ +name: Container + +# Builds the image and starts it with docker-compose.yml, then checks each service listens on +# every port compose maps for it. Not a required check, so a paths filter is safe here. +on: + push: + branches: [ development ] + paths: [ Dockerfile, .dockerignore, docker-compose.yml, global.json, Directory.Packages.props, 'navmesh/**', 'src/**', .github/workflows/container.yml ] + pull_request: + paths: [ Dockerfile, .dockerignore, docker-compose.yml, global.json, Directory.Packages.props, 'navmesh/**', 'src/**', .github/workflows/container.yml ] + +permissions: + contents: read + +concurrency: + group: container-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + smoke: + name: container smoke test + runs-on: ubuntu-24.04 + timeout-minutes: 20 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 + # The layer cache is read on every run and written only from development, for the same + # reason as the NuGet cache in dotnet.yml: a PR's cache can only be read by that PR. + - name: Build the image + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 + with: + context: . + load: true + push: false + tags: rasa_net + cache-from: type=gha + cache-to: ${{ github.event_name == 'push' && 'type=gha,mode=max' || '' }} + # Compose mounts these from the repository root. The image runs as UID 1654, so they must be + # writable by it. + - name: Create the mounted files + run: | + touch rasaauth.db rasachar.db rasaworld.db + echo '{}' > appsettings.env.json + chmod 666 rasaauth.db rasachar.db rasaworld.db appsettings.env.json + - run: docker compose up --detach --no-build + - name: Wait for both servers + run: | + ready() { docker compose logs --no-color "$1" 2>&1 | grep -q "$2"; } + for _ in $(seq 120); do + if ready auth 'Listening for clients on port' && ready game 'Server ready!'; then + exit 0 + fi + if [ -n "$(docker compose ps --status exited --quiet)" ]; then + echo "::error::A service exited during startup." + exit 1 + fi + sleep 2 + done + echo "::error::The servers weren't ready within 4 minutes." + exit 1 + # Probed from inside each container: a probe of the published port on the host always + # connects, because Docker's proxy accepts the connection itself whether or not anything + # listens behind it. UDP can't be probed this way, so its startup log line is checked. + - name: Check every mapped port + run: | + status=0 + config=$(docker compose config --format json) + for service in auth game; do + for port in $(jq -r --arg s "$service" '.services[$s].ports[]? | select((.protocol // "tcp") == "tcp") | .target' <<< "$config"); do + if docker compose exec -T "$service" bash -c "exec 3<>/dev/tcp/127.0.0.1/$port" 2>/dev/null; then + echo "$service listens on tcp/$port" + else + echo "::error::$service doesn't listen on tcp/$port, which docker-compose.yml maps." + status=1 + fi + done + for port in $(jq -r --arg s "$service" '.services[$s].ports[]? | select(.protocol == "udp") | .target' <<< "$config"); do + if docker compose logs --no-color "$service" | grep -q "UDP port $port"; then + echo "$service listens on udp/$port" + else + echo "::error::$service never logged listening on udp/$port, which docker-compose.yml maps." + status=1 + fi + done + done + exit $status + - name: Check the image runs as a non-root user + run: test "$(docker run --rm rasa_net id -u)" = 1654 + # The containers are left running so their logs can be read here. + - name: Service logs + if: always() + run: docker compose logs --no-color + # Advisory: reports known HIGH and CRITICAL vulnerabilities without failing the job. Pinned by + # commit: the trivy-action tags were overwritten with a credential stealer in March 2026 + # (CVE-2026-33634). + - name: Scan the image + if: always() + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 + with: + image-ref: rasa_net + severity: HIGH,CRITICAL + ignore-unfixed: true + exit-code: '0' diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 095aa044..c4bb33f8 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -78,7 +78,7 @@ jobs: uses: actions/cache/restore@v6 with: path: ~/.nuget/packages - key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj', 'global.json', '.config/dotnet-tools.json') }} + key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj', 'Directory.Packages.props', 'global.json', '.config/dotnet-tools.json') }} restore-keys: nuget-${{ runner.os }}- - name: Restore dependencies run: dotnet restore @@ -178,10 +178,45 @@ jobs: path: TestResults/ retention-days: 14 + # Fails when any of the six EF contexts has model changes no migration covers. The design-time + # factories use a fixed MySQL server version, so no database server is needed. + drift: + name: migration drift + needs: changes + if: needs.changes.outputs.code == 'true' + runs-on: ubuntu-24.04 + timeout-minutes: 15 + steps: + - uses: actions/checkout@v7 + - name: Setup .NET + uses: actions/setup-dotnet@v6 + with: + global-json-file: global.json + - name: Restore NuGet cache + uses: actions/cache/restore@v6 + with: + path: ~/.nuget/packages + key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj', 'Directory.Packages.props', 'global.json', '.config/dotnet-tools.json') }} + restore-keys: nuget-${{ runner.os }}- + - run: dotnet tool restore + - run: dotnet build src/Rasa.Game + - name: Check every context for pending model changes + run: | + status=0 + for context in SqliteAuthContext MySqlAuthContext SqliteCharContext MySqlCharContext SqliteWorldContext MySqlWorldContext; do + echo "::group::$context" + if ! dotnet ef migrations has-pending-model-changes --no-build --project src/Rasa.DBL --startup-project src/Rasa.Game --context "$context"; then + echo "::error::$context has model changes without a migration (or could not be checked)." + status=1 + fi + echo "::endgroup::" + done + exit $status + # The one check to require: it passes when every lane passed, or when the PR changed only docs. tests-complete: name: tests complete - needs: [ changes, build, test ] + needs: [ changes, build, test, drift ] if: always() runs-on: ubuntu-24.04 timeout-minutes: 5 @@ -196,6 +231,7 @@ jobs: CHANGES: ${{ needs.changes.result }} BUILD: ${{ needs.build.result }} TEST: ${{ needs.test.result }} + DRIFT: ${{ needs.drift.result }} run: | if [ "$CHANGES" = success ] && [ "$CODE" = false ]; then echo "Docs-only change; tests skipped." @@ -208,7 +244,7 @@ jobs: failed=$((failed + $(grep -o ']*outcome="Failed"' "$trx" | wc -l))) done echo "Across all lanes: $total results, $passed passed, $failed failed." | tee -a "$GITHUB_STEP_SUMMARY" - if [ "$BUILD" != success ] || [ "$TEST" != success ]; then - echo "::error::build: $BUILD, test runners: $TEST" + if [ "$BUILD" != success ] || [ "$TEST" != success ] || [ "$DRIFT" != success ]; then + echo "::error::build: $BUILD, test runners: $TEST, migration drift: $DRIFT" exit 1 fi diff --git a/.github/workflows/prune-container.yml b/.github/workflows/prune-container.yml new file mode 100644 index 00000000..257fe428 --- /dev/null +++ b/.github/workflows/prune-container.yml @@ -0,0 +1,41 @@ +name: Prune container + +# Weekly, off the release's critical path: keeps the 14 newest image versions in GHCR and +# deletes older ones. A version is an image digest; releases are pushed without attestations, +# so each release is exactly one version and latest/development are always among the kept. +on: + schedule: + - cron: '17 4 * * 1' + workflow_dispatch: + +permissions: + contents: read + +jobs: + prune: + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + packages: write + steps: + # Nothing to prune until the first release (or on a fork that never released). + - name: Check the package exists + id: package + env: + GH_TOKEN: ${{ github.token }} + OWNER: ${{ github.repository_owner }} + REPO: ${{ github.repository }} + run: | + kind=users + [ "$(gh api "repos/$REPO" --jq .owner.type)" = Organization ] && kind=orgs + if gh api "$kind/$OWNER/packages/container/rasa.net" > /dev/null 2>&1; then + echo "exists=true" >> "$GITHUB_OUTPUT" + else + echo "::notice::No rasa.net container package yet; nothing to prune." + fi + - if: steps.package.outputs.exists == 'true' + uses: actions/delete-package-versions@e5bc658cc4c965c472efe991f8beea3981499c55 # v5.0.0 + with: + package-name: rasa.net + package-type: container + min-versions-to-keep: 14 diff --git a/.github/workflows/release-container.yml b/.github/workflows/release-container.yml new file mode 100644 index 00000000..4fd59179 --- /dev/null +++ b/.github/workflows/release-container.yml @@ -0,0 +1,89 @@ +name: Release container + +# Builds development's HEAD and pushes it to GHCR. Started by a maintainer's /release comment +# (release-on-comment.yml) or by hand from the Actions tab. +on: + workflow_dispatch: + inputs: + requested_by: + description: Who asked for the release + required: false + pull_request: + description: The PR the /release comment was on + required: false + +permissions: + contents: read + +concurrency: + group: release-container + cancel-in-progress: false + +jobs: + release: + runs-on: ubuntu-24.04 + timeout-minutes: 30 + permissions: + contents: read + actions: read + packages: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: development + # Only a commit whose CI passed is released. + - name: Check CI passed on this commit + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + run: | + sha=$(git rev-parse HEAD) + if [ -z "$(gh run list -R "$REPO" --workflow dotnet.yml --commit "$sha" --status success --json databaseId --jq '.[].databaseId')" ]; then + echo "::error::CI hasn't passed on development's HEAD ($sha) yet; release after it does." + exit 1 + fi + # GHCR names must be lowercase. The dated tag is what gets kept and pruned; latest and + # development always point at the newest release. + - name: Name the image + id: name + env: + REPO: ${{ github.repository }} + run: | + { + echo "image=ghcr.io/${REPO,,}" + echo "dated=$(date -u +%Y%m%d-%H%M)-$(git rev-parse --short=7 HEAD)" + echo "revision=$(git rev-parse HEAD)" + } >> "$GITHUB_OUTPUT" + - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 + - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ github.token }} + # No provenance or SBOM: they're pushed as untagged manifests, which the prune workflow + # would count and could delete out from under their image. + - uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 + with: + context: . + push: true + provenance: false + sbom: false + tags: | + ${{ steps.name.outputs.image }}:${{ steps.name.outputs.dated }} + ${{ steps.name.outputs.image }}:development + ${{ steps.name.outputs.image }}:latest + labels: | + org.opencontainers.image.source=https://github.com/${{ github.repository }} + org.opencontainers.image.revision=${{ steps.name.outputs.revision }} + cache-from: type=gha + - name: Summary + env: + IMAGE: ${{ steps.name.outputs.image }} + DATED: ${{ steps.name.outputs.dated }} + REQUESTED_BY: ${{ inputs.requested_by }} + PR: ${{ inputs.pull_request }} + run: | + echo "Pushed \`$IMAGE:$DATED\` (also \`development\` and \`latest\`)." >> "$GITHUB_STEP_SUMMARY" + if [ -n "$REQUESTED_BY" ]; then + echo "Requested by @$REQUESTED_BY${PR:+ on #$PR}." >> "$GITHUB_STEP_SUMMARY" + fi diff --git a/.github/workflows/release-on-comment.yml b/.github/workflows/release-on-comment.yml new file mode 100644 index 00000000..65835810 --- /dev/null +++ b/.github/workflows/release-on-comment.yml @@ -0,0 +1,58 @@ +name: Release on comment + +# A maintainer comments "/release" on a merged PR to publish development's HEAD as a container +# image. issue_comment workflows always run from development's copy of this file, so a PR can't +# change what this does; but anyone can comment, so the commenter's permission is checked. +on: + issue_comment: + types: [ created ] + +permissions: + contents: read + +jobs: + release: + if: github.event.issue.pull_request && startsWith(github.event.comment.body, '/release') + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + actions: write + issues: write + pull-requests: read + steps: + # Every comment field arrives through env, never ${{ }} inside the script. + - env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + BODY: ${{ github.event.comment.body }} + COMMENTER: ${{ github.event.comment.user.login }} + COMMENT_ID: ${{ github.event.comment.id }} + PR: ${{ github.event.issue.number }} + run: | + react() { + gh api --method POST "repos/$REPO/issues/comments/$COMMENT_ID/reactions" -f content="$1" > /dev/null + } + + # "/release" alone or followed by whitespace; "/releasenotes" isn't a command. + if ! [[ "$BODY" =~ ^/release([[:space:]]|$) ]]; then + echo "Not a /release command." + exit 0 + fi + + permission=$(gh api "repos/$REPO/collaborators/$COMMENTER/permission" --jq .permission 2>/dev/null || echo none) + if [ "$permission" != admin ] && [ "$permission" != write ]; then + echo "::warning::$COMMENTER has '$permission' permission; /release needs write, maintain or admin." + react -1 + exit 0 + fi + + if [ "$(gh api "repos/$REPO/pulls/$PR" --jq '.merged and .base.ref == "development"')" != true ]; then + echo "::warning::PR #$PR isn't merged into development." + react -1 + exit 0 + fi + + react eyes + gh workflow run release-container.yml -R "$REPO" --ref development \ + -f requested_by="$COMMENTER" -f pull_request="$PR" + react rocket diff --git a/docs/setup.md b/docs/setup.md index daea0e94..9d67b088 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -33,6 +33,17 @@ roll-forward is disabled so local builds, CI and Docker use the same version. - [Download the .NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) and install version **10.0.401**. The SDK includes the runtime. - From the repository root, run `dotnet --version` and verify `10.0.401`. +#### Updating the SDK or packages + +The SDK version lives in two places: `global.json` and the `sdk` build stage in +the `Dockerfile`. NuGet versions all live in `Directory.Packages.props`, and the +`dotnet-ef` tool in `.config/dotnet-tools.json` follows the EF Core packages. +Change every copy in the same commit; `PlatformCompatibilityTests` fails when +they disagree. Dependabot opens grouped update PRs (`.github/dependabot.yml`); +an SDK PR from Dependabot changes `global.json` only, so push the matching +`Dockerfile` tag to that PR's branch before merging it. EF Core 10 and Pomelo +updates are ignored until the project chooses a MySQL provider for EF Core 10. + The supported portable deployment identifiers are `win-x64`, `osx-x64` and `linux-x64`. These preserve the Windows, macOS and Linux x64 deployment families, not support for the obsolete operating-system versions named by the old .NET 5 identifiers. Use an operating system supported by .NET 10. ### Optional: Install MySQL Server and MySQL Workbench @@ -190,17 +201,20 @@ First restore the solution and the repository-local EF tool from the repository - Open powershell - `dotnet restore` - `dotnet tool restore` -- `dotnet ef --version` (expected: `9.0.20`) +- `dotnet ef --version` (expected: the `dotnet-ef` version in `.config/dotnet-tools.json`) Before upgrading an existing database, back it up and test these commands on a disposable copy. Keep `__EFMigrationsHistory`; do not use `EnsureCreated`, delete the database, or suppress pending-model errors to bypass an upgrade failure. SQLite applies migrations automatically on server startup; MySQL requires the commands below before starting the servers. -Before declaring content or gameplay work ready, also check for provider/model -drift from the repository root: +Before declaring content or gameplay work ready, also check all six contexts for +provider/model drift from the repository root. CI's `migration drift` job runs the +same commands and fails on any pending change. None of them needs a database server. -- `dotnet ef migrations has-pending-model-changes --project src\Rasa.DBL --startup-project src\Rasa.Game --context SqliteWorldContext` -- `dotnet ef migrations has-pending-model-changes --project src\Rasa.DBL --startup-project src\Rasa.Game --context MySqlWorldContext` -- `dotnet ef migrations has-pending-model-changes --project src\Rasa.DBL --startup-project src\Rasa.Game --context SqliteCharContext` -- `dotnet ef migrations has-pending-model-changes --project src\Rasa.DBL --startup-project src\Rasa.Game --context MySqlCharContext` +- `dotnet ef migrations has-pending-model-changes --project src/Rasa.DBL --startup-project src/Rasa.Game --context SqliteAuthContext` +- `dotnet ef migrations has-pending-model-changes --project src/Rasa.DBL --startup-project src/Rasa.Game --context MySqlAuthContext` +- `dotnet ef migrations has-pending-model-changes --project src/Rasa.DBL --startup-project src/Rasa.Game --context SqliteCharContext` +- `dotnet ef migrations has-pending-model-changes --project src/Rasa.DBL --startup-project src/Rasa.Game --context MySqlCharContext` +- `dotnet ef migrations has-pending-model-changes --project src/Rasa.DBL --startup-project src/Rasa.Game --context SqliteWorldContext` +- `dotnet ef migrations has-pending-model-changes --project src/Rasa.DBL --startup-project src/Rasa.Game --context MySqlWorldContext` If required mission content is broken, `Rasa.Game` now logs each actionable mission diagnostic and refuses to print `Server ready!` until the content is @@ -405,7 +419,7 @@ Use `--map adv_foreas_concordia_wilderness` to rebuild one map. Generated files ### Database compatibility tests -The compatibility tests verify the pinned SDK, all solution project targets, the retained navigation project/package references, cryptographic fixtures, connection-string-specific MySQL server-version caching, and deterministic migration-lock names for schemas through MySQL's 64-character limit. The MySQL configuration tests use a fixed server version and do not connect to a database. +The compatibility tests verify that the SDK, Docker images and package versions agree across the files that pin them, all solution project targets, the retained navigation project/package references, the Docker image layout, cryptographic fixtures, connection-string-specific MySQL server-version caching, and deterministic migration-lock names for schemas through MySQL's 64-character limit. The MySQL configuration tests use a fixed server version and do not connect to a database. ```powershell dotnet test src\Rasa.Test\Rasa.Test.csproj --filter "FullyQualifiedName~Compatibility" diff --git a/src/Rasa.DBL/Context/Auth/DesignTimeMySqlAuthContextFactory.cs b/src/Rasa.DBL/Context/Auth/DesignTimeMySqlAuthContextFactory.cs index d8ad396f..8474545d 100644 --- a/src/Rasa.DBL/Context/Auth/DesignTimeMySqlAuthContextFactory.cs +++ b/src/Rasa.DBL/Context/Auth/DesignTimeMySqlAuthContextFactory.cs @@ -1,10 +1,18 @@ -using Microsoft.EntityFrameworkCore.Design; +using System; + +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; using JetBrains.Annotations; namespace Rasa.Context.Auth { using Configuration; + using Configuration.ConnectionStrings; + using Configuration.ContextSetup; + using Services.DbContext; /// /// Used by EF Core to create and execute migrations. @@ -14,7 +22,20 @@ public class DesignTimeMySqlAuthContextFactory : DesignTimeContextFactoryBase, I { public MySqlAuthContext CreateDbContext(string[] args) { - return CreateDbContext(DatabaseProvider.MySql); + var configuration = new ConfigurationBuilder() + .AddJsonFile("databasesettings.json", false, false) + .AddJsonFile("databasesettings.env.json", true, false) + .Build(); + var databases = new DatabaseConfiguration(); + configuration.GetSection("Databases").Bind(databases); + databases.Provider = DatabaseProvider.MySql.ToString(); + + return new MySqlAuthContext( + Options.Create(databases), + new MySqlDbContextConfigurationService( + new MySqlConnectionStringFactory(), + _ => new MySqlServerVersion(new Version(8, 4, 0))), + new MySqlDbContextPropertyModifier()); } } } \ No newline at end of file From 914203cfa8d368847819ae3979d7bbd4927218b7 Mon Sep 17 00:00:00 2001 From: Steven Cady Date: Mon, 5 Oct 2026 08:38:09 -0500 Subject: [PATCH 04/10] Document the non-root image, Linux file ownership and released images Refs #132 (CI-7, CI-8, CI-9). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AxABTHjs6nJrsXVCTnmMo7 --- docs/docker_setup.md | 33 +++++++++++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/docs/docker_setup.md b/docs/docker_setup.md index 87f79575..24bbd85b 100644 --- a/docs/docker_setup.md +++ b/docs/docker_setup.md @@ -2,8 +2,10 @@ This provides an alternative to building and using the project directly on your system. Use Docker with Linux containers and Docker Compose v2. -The Dockerfile builds all .NET 10 projects with SDK **10.0.401**, matching -`global.json` and CI. Each Compose service uses its Release output directory as +The Dockerfile builds all .NET 10 projects in the SDK image `global.json` pins +(the same SDK as CI), then copies the Auth and Game Release output into the much +smaller .NET 10 runtime image, which runs as its built-in non-root `app` user +(UID 1654). Each Compose service uses its Release output directory as its working directory. This matches the runtime loaders, which resolve `appsettings.json`, `appsettings.env.json`, `databasesettings.json`, and `databasesettings.env.json` from the process working directory. The three @@ -47,6 +49,14 @@ foreach ($file in 'rasaauth.db', 'rasachar.db', 'rasaworld.db') { docker compose build ``` +On a Linux host, the container's `app` user (UID 1654) must be able to write the +mounted files. Docker Desktop on Windows and macOS doesn't need this. + +```sh +touch rasaauth.db rasachar.db rasaworld.db appsettings.env.json +sudo chown 1654:1654 rasaauth.db rasachar.db rasaworld.db appsettings.env.json +``` + This branch's consolidated migration history requires fresh databases, including when replacing databases from earlier versions of this branch. Do not run this as an existing-save conversion procedure or edit migration @@ -85,6 +95,25 @@ Release-build output placement from the project files. This verifies configuration, SQLite, knowledge-base, and navmesh paths without relying on a host `bin` directory as proof of image contents. +CI's `Container` workflow (`.github/workflows/container.yml`) builds the image +on PRs that touch it, starts it with this Compose file, and checks from inside +each container that every port Compose maps is listening. + +## Use a released image + +Maintainers publish `development` to the GitHub Container Registry by commenting +`/release` on a merged PR. To run a release instead of building locally, pull it +and tag it with the name Compose uses: + +```sh +docker pull ghcr.io/infiniterasa/rasa.net:latest +docker tag ghcr.io/infiniterasa/rasa.net:latest rasa_net +docker compose up --no-build +``` + +Each release also has a dated tag (`-`); the 14 newest +are kept. + ## Start Server Next, run `docker compose up --build`. From 1be2a235d087ebd53a09d4b4353b01a54a2dbd4e Mon Sep 17 00:00:00 2001 From: Steven Cady Date: Mon, 5 Oct 2026 08:38:17 -0500 Subject: [PATCH 05/10] fixup! Document the non-root image, Linux file ownership and released images --- docs/docker_setup.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/docker_setup.md b/docs/docker_setup.md index 24bbd85b..114efa55 100644 --- a/docs/docker_setup.md +++ b/docs/docker_setup.md @@ -50,11 +50,11 @@ docker compose build ``` On a Linux host, the container's `app` user (UID 1654) must be able to write the -mounted files. Docker Desktop on Windows and macOS doesn't need this. +mounted databases. Docker Desktop on Windows and macOS doesn't need this. ```sh -touch rasaauth.db rasachar.db rasaworld.db appsettings.env.json -sudo chown 1654:1654 rasaauth.db rasachar.db rasaworld.db appsettings.env.json +touch rasaauth.db rasachar.db rasaworld.db +sudo chown 1654:1654 rasaauth.db rasachar.db rasaworld.db ``` This branch's consolidated migration history requires fresh databases, From ea4aa0133ae5f5fb888df5574fbe25ee4a5de0b5 Mon Sep 17 00:00:00 2001 From: Steven Cady Date: Mon, 5 Oct 2026 08:42:56 -0500 Subject: [PATCH 06/10] Create MySQL evidence notes as text so a world database builds from empty SeedWorldContent writes today's Bootcamp evidence, and some of its notes are longer than the varchar(256) ConsolidatedWorldSchema created, so `dotnet ef database update --context MySqlWorldContext` on an empty server stopped with "Data too long for column 'reconstruction_note'". Sqlite doesn't enforce the width, and the existing boundary test only sees InsertDataOperation rows, not the seed's raw SQL inserts. Databases already past the seed were widened by WildernessAliaBranches and WildernessEvidenceCapacity; for them this changes nothing. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AxABTHjs6nJrsXVCTnmMo7 --- .../MySqlWorld/20260926190344_ConsolidatedWorldSchema.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/Rasa.DBL/Migrations/MySqlWorld/20260926190344_ConsolidatedWorldSchema.cs b/src/Rasa.DBL/Migrations/MySqlWorld/20260926190344_ConsolidatedWorldSchema.cs index 2665eef7..1661a15c 100644 --- a/src/Rasa.DBL/Migrations/MySqlWorld/20260926190344_ConsolidatedWorldSchema.cs +++ b/src/Rasa.DBL/Migrations/MySqlWorld/20260926190344_ConsolidatedWorldSchema.cs @@ -393,7 +393,12 @@ protected override void Up(MigrationBuilder migrationBuilder) local_client_path = table.Column(type: "varchar(256)", nullable: true) .Annotation("MySql:CharSet", "utf8mb4"), confidence = table.Column(type: "double unsigned", nullable: false), - reconstruction_note = table.Column(type: "varchar(256)", nullable: false) + // text, not the varchar(256) this first shipped with: SeedWorldContent, next, + // writes today's Bootcamp evidence, some of whose notes are longer, and MySQL + // refused them ("Data too long"), so no MySQL world database could be built from + // empty. Databases already past this point were widened by + // WildernessAliaBranches and WildernessEvidenceCapacity. + reconstruction_note = table.Column(type: "text", nullable: false) .Annotation("MySql:CharSet", "utf8mb4") }, constraints: table => From 5a61173865248ef3a780fae144dc3582c74f30d6 Mon Sep 17 00:00:00 2001 From: Steven Cady Date: Mon, 5 Oct 2026 08:42:56 -0500 Subject: [PATCH 07/10] CI: test the MySQL contexts against real MySQL 8.0 and 8.4 (#132: CI-6) A `mysql` job, matrixed over mysql:8.0 and mysql:8.4 service containers, applies all three MySQL contexts' migrations to an empty server with `dotnet ef database update`, then runs the new `MySql` test category against it. It fails unless every test in the category ran and passed, and `tests complete` now requires it. The MySql tests (MySqlLiveDatabaseTests) check that every migration is applied with none pending, that every table holds the same rows as after Sqlite's migrations, and that accounts, characters and missions round-trip through the repositories with MySQL's collation and foreign keys. They find the server through RASA_TEST_MYSQL and report inconclusive without it. Config stays file-only: the job writes a databasesettings.env.json for the design-time factories. TestShards.cs leaves the category out of the plan and every lane's filter excludes it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AxABTHjs6nJrsXVCTnmMo7 --- .github/scripts/TestShards.cs | 34 +++- .github/workflows/dotnet.yml | 86 +++++++- docs/setup.md | 23 +++ src/Rasa.Test/Database/LiveMySql.cs | 69 +++++++ .../Database/MySqlLiveDatabaseTests.cs | 190 ++++++++++++++++++ 5 files changed, 393 insertions(+), 9 deletions(-) create mode 100644 src/Rasa.Test/Database/LiveMySql.cs create mode 100644 src/Rasa.Test/Database/MySqlLiveDatabaseTests.cs diff --git a/.github/scripts/TestShards.cs b/.github/scripts/TestShards.cs index 33e5c8b6..9d038419 100644 --- a/.github/scripts/TestShards.cs +++ b/.github/scripts/TestShards.cs @@ -14,6 +14,9 @@ // // Every lane but the last lists what it runs; the last runs everything the others don't list. So a // test this script fails to find, or one added since, still runs exactly once, in the last lane. +// +// Tests in an excluded category (ExcludedCategories below: the live-MySQL tests, which run in their +// own job against a MySQL server) are left out of the plan, and every lane's filter excludes them. using System.Reflection; using System.Reflection.Metadata; @@ -26,6 +29,8 @@ return 2; } +string[] ExcludedCategories = ["MySql"]; + var assemblyPath = args[0]; var laneCount = int.Parse(args[1]); var outputDir = args[2]; @@ -37,7 +42,7 @@ return 2; } -var classes = FindTests(assemblyPath); +var classes = FindTests(assemblyPath, ExcludedCategories); if (classes.Count == 0) { Console.Error.WriteLine($"no [TestClass] types found in {assemblyPath}"); @@ -85,6 +90,8 @@ double ClassWeight(string cls) => filter = listed.Count > 0 ? string.Join("&", listed.Select(t => t.Replace("=", "!="))) : "FullyQualifiedName!=__run_everything__"; + // & binds tighter than |, so a lane's list of alternatives is grouped before the exclusion. + filter = $"({filter})" + string.Concat(ExcludedCategories.Select(c => $"&TestCategory!={c}")); File.WriteAllText(Path.Combine(outputDir, $"lane-{i}.filter"), filter); } @@ -111,8 +118,9 @@ double ClassWeight(string cls) => File.AppendAllLines(summary, report.Prepend("### Test lanes").Append("")); return 0; -// Test class full name -> test method name -> number of cases (each [DataRow] is one). -static Dictionary> FindTests(string path) +// Test class full name -> test method name -> number of cases (each [DataRow] is one). A method +// with, or in a class with, a [TestCategory] in excluded isn't listed. +static Dictionary> FindTests(string path, string[] excluded) { using var stream = File.OpenRead(path); using var pe = new PEReader(stream); @@ -122,7 +130,8 @@ static Dictionary> FindTests(string path) foreach (var handle in md.TypeDefinitions) { var type = md.GetTypeDefinition(handle); - if ((type.Attributes & TypeAttributes.Abstract) != 0 || !HasAttribute(md, type.GetCustomAttributes(), "TestClassAttribute")) + if ((type.Attributes & TypeAttributes.Abstract) != 0 || !HasAttribute(md, type.GetCustomAttributes(), "TestClassAttribute") + || Categories(md, type.GetCustomAttributes()).Intersect(excluded).Any()) continue; var methods = new Dictionary(StringComparer.Ordinal); @@ -130,7 +139,8 @@ static Dictionary> FindTests(string path) { var method = md.GetMethodDefinition(methodHandle); var attributes = method.GetCustomAttributes(); - if (!HasAttribute(md, attributes, "TestMethodAttribute") && !HasAttribute(md, attributes, "DataTestMethodAttribute")) + if (!HasAttribute(md, attributes, "TestMethodAttribute") && !HasAttribute(md, attributes, "DataTestMethodAttribute") + || Categories(md, attributes).Intersect(excluded).Any()) continue; methods[md.GetString(method.Name)] = Math.Max(1, attributes.Count(a => AttributeName(md, md.GetCustomAttribute(a)) == "DataRowAttribute")); @@ -146,6 +156,20 @@ static Dictionary> FindTests(string path) static bool HasAttribute(MetadataReader md, CustomAttributeHandleCollection attributes, string name) => attributes.Any(a => AttributeName(md, md.GetCustomAttribute(a)) == name); +// The names in [TestCategory("...")] attributes: a blob of the 0x0001 prolog and one string argument. +static IEnumerable Categories(MetadataReader md, CustomAttributeHandleCollection attributes) +{ + foreach (var handle in attributes) + { + var attribute = md.GetCustomAttribute(handle); + if (AttributeName(md, attribute) != "TestCategoryAttribute") + continue; + var blob = md.GetBlobReader(attribute.Value); + if (blob.ReadUInt16() == 1 && blob.ReadSerializedString() is { } category) + yield return category; + } +} + static string? AttributeName(MetadataReader md, CustomAttribute attribute) { switch (attribute.Constructor.Kind) diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index c4bb33f8..9ff375f4 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -205,7 +205,7 @@ jobs: status=0 for context in SqliteAuthContext MySqlAuthContext SqliteCharContext MySqlCharContext SqliteWorldContext MySqlWorldContext; do echo "::group::$context" - if ! dotnet ef migrations has-pending-model-changes --no-build --project src/Rasa.DBL --startup-project src/Rasa.Game --context "$context"; then + if ! dotnet ef migrations has-pending-model-changes --no-build --project src/Rasa.DBL --startup-project src/Rasa.Game --context "$context"; then echo "::error::$context has model changes without a migration (or could not be checked)." status=1 fi @@ -213,10 +213,87 @@ jobs: done exit $status + # The MySql test category against a real server, after every MySQL migration is applied to it + # from empty, the way a MySQL deployment gets its schema. The other lanes leave this category + # out (TestShards.cs) and only build the MySQL model offline. + mysql: + name: mysql (${{ matrix.mysql }}) + needs: changes + if: needs.changes.outputs.code == 'true' + runs-on: ubuntu-24.04 + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + mysql: [ '8.0', '8.4' ] + services: + mysql: + image: mysql:${{ matrix.mysql }} + env: + MYSQL_ROOT_PASSWORD: rasa-ci + ports: + - 3306:3306 + options: >- + --health-cmd "mysqladmin ping -h 127.0.0.1 -uroot -prasa-ci" + --health-interval 5s + --health-timeout 5s + --health-retries 30 + env: + RASA_TEST_MYSQL: Server=127.0.0.1;Port=3306;User ID=root;Password=rasa-ci + steps: + - uses: actions/checkout@v7 + - name: Setup .NET + uses: actions/setup-dotnet@v6 + with: + global-json-file: global.json + - name: Restore NuGet cache + uses: actions/cache/restore@v6 + with: + path: ~/.nuget/packages + key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj', 'Directory.Packages.props', 'global.json', '.config/dotnet-tools.json') }} + restore-keys: nuget-${{ runner.os }}- + # The design-time factories read databasesettings.json and then databasesettings.env.json from + # the build output, the same files the servers read; the override points them at the service. + # The timeout is also the command timeout, and the seed migrations insert thousands of rows. + - name: Point the database settings at the service + run: | + jq '.Databases |= ((.Auth, .Char, .World) |= (.Host = "127.0.0.1" | .Port = 3306 | .User = "root" | .Password = "rasa-ci" + | .TimeoutInMilliseconds = 600000))' \ + src/Rasa.DBL/databasesettings.json > src/Rasa.DBL/databasesettings.env.json + - run: dotnet tool restore + - name: Build + run: dotnet build src/Rasa.Test + - name: Apply the MySQL migrations + run: | + for context in MySqlAuthContext MySqlCharContext MySqlWorldContext; do + dotnet ef database update --no-build --project src/Rasa.DBL --startup-project src/Rasa.Game --context "$context" + done + - name: Test + run: | + dotnet test src/Rasa.Test --no-build \ + --filter TestCategory=MySql \ + --blame-hang-timeout 10m \ + --logger "trx;LogFileName=mysql.trx" \ + --results-directory TestResults + ran=$(grep -o ']*outcome="Passed"' TestResults/mysql.trx | wc -l) + echo "MySQL ${{ matrix.mysql }}: $ran results, $passed passed." | tee -a "$GITHUB_STEP_SUMMARY" + if [ "$passed" -eq 0 ] || [ "$passed" -ne "$ran" ]; then + echo "::error::Every MySql test must run and pass against the server ($passed of $ran passed)." + exit 1 + fi + - name: Upload test results + if: always() + uses: actions/upload-artifact@v7 + with: + name: mysql-results-${{ matrix.mysql }} + path: TestResults/ + retention-days: 14 + # The one check to require: it passes when every lane passed, or when the PR changed only docs. tests-complete: name: tests complete - needs: [ changes, build, test, drift ] + needs: [ changes, build, test, drift, mysql ] if: always() runs-on: ubuntu-24.04 timeout-minutes: 5 @@ -232,6 +309,7 @@ jobs: BUILD: ${{ needs.build.result }} TEST: ${{ needs.test.result }} DRIFT: ${{ needs.drift.result }} + MYSQL: ${{ needs.mysql.result }} run: | if [ "$CHANGES" = success ] && [ "$CODE" = false ]; then echo "Docs-only change; tests skipped." @@ -244,7 +322,7 @@ jobs: failed=$((failed + $(grep -o ']*outcome="Failed"' "$trx" | wc -l))) done echo "Across all lanes: $total results, $passed passed, $failed failed." | tee -a "$GITHUB_STEP_SUMMARY" - if [ "$BUILD" != success ] || [ "$TEST" != success ] || [ "$DRIFT" != success ]; then - echo "::error::build: $BUILD, test runners: $TEST, migration drift: $DRIFT" + if [ "$BUILD" != success ] || [ "$TEST" != success ] || [ "$DRIFT" != success ] || [ "$MYSQL" != success ]; then + echo "::error::build: $BUILD, test runners: $TEST, migration drift: $DRIFT, mysql: $MYSQL" exit 1 fi diff --git a/docs/setup.md b/docs/setup.md index 9d67b088..96209e5a 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -425,6 +425,29 @@ The compatibility tests verify that the SDK, Docker images and package versions dotnet test src\Rasa.Test\Rasa.Test.csproj --filter "FullyQualifiedName~Compatibility" ``` +### Live MySQL tests + +The tests in the `MySql` category run against a real MySQL server: every MySQL migration applied from empty, the same row counts as Sqlite in every table afterwards, and accounts, characters and missions written and read back through the repositories. CI runs them against MySQL 8.0 and 8.4 in the `mysql` job and leaves them out of the other test lanes. Without a server, `dotnet test` reports them as skipped. + +To run them locally, start a throwaway server (`--tmpfs` keeps its data in memory, which makes the seed migrations much faster): + +```powershell +docker run -d --name rasa-mysql -p 3306:3306 --tmpfs /var/lib/mysql -e MYSQL_ROOT_PASSWORD=rasa-ci mysql:8.4 +``` + +Point the design-time factories at it with a `src\Rasa.DBL\databasesettings.env.json` (see [Database configuration](#database-configuration)) that sets `Host` `127.0.0.1`, `User` `root`, `Password` `rasa-ci` and a `TimeoutInMilliseconds` of a few minutes for each of `Auth`, `Char` and `World`; the timeout is also the command timeout, and the World seed is large. Then build, apply the migrations, and run the category with the server in `RASA_TEST_MYSQL`: + +```powershell +dotnet build src\Rasa.Test +dotnet ef database update --no-build --project src\Rasa.DBL --startup-project src\Rasa.Game --context MySqlAuthContext +dotnet ef database update --no-build --project src\Rasa.DBL --startup-project src\Rasa.Game --context MySqlCharContext +dotnet ef database update --no-build --project src\Rasa.DBL --startup-project src\Rasa.Game --context MySqlWorldContext +$env:RASA_TEST_MYSQL = "Server=127.0.0.1;Port=3306;User ID=root;Password=rasa-ci" +dotnet test src\Rasa.Test --no-build --filter TestCategory=MySql +``` + +The tests use the database names from `databasesettings.json` and remove the rows they add. Delete `databasesettings.env.json` afterwards, or the servers you run from that build will read it too. + ### Create a game user The authentication server can be used to create a user by running a command in the terminal. The usage is: `create `. Running this command will create a new user in the database that you can use to login with the game client. diff --git a/src/Rasa.Test/Database/LiveMySql.cs b/src/Rasa.Test/Database/LiveMySql.cs new file mode 100644 index 00000000..5eedf890 --- /dev/null +++ b/src/Rasa.Test/Database/LiveMySql.cs @@ -0,0 +1,69 @@ +using System; +using System.IO; +using System.Text.Json; +using Microsoft.Extensions.Options; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using MySqlConnector; + +namespace Rasa.Test.Database +{ + using Rasa.Configuration; + using Rasa.Configuration.ConnectionStrings; + using Rasa.Configuration.ContextSetup; + using Rasa.Context; + using Rasa.Services.DbContext; + + /// + /// Contexts on a live MySQL server, for the tests in the "MySql" category. The server comes from + /// RASA_TEST_MYSQL, a MySqlConnector connection string without a database + /// ("Server=127.0.0.1;Port=3306;User ID=root;Password=..."); the database names are the ones + /// databasesettings.json gives, which is where `dotnet ef database update` applied the + /// migrations. Contexts are configured the way the servers configure them (server version + /// detection, the bounded migration lock), not with the offline setup the model tests use. + /// + /// CI runs this category in its own job against mysql:8.0 and mysql:8.4 and leaves it out of + /// the other lanes; without RASA_TEST_MYSQL the tests report inconclusive. docs/setup.md + /// has the commands to run them locally. + /// + internal static class LiveMySql + { + internal const string Category = "MySql"; + internal const string ServerVariable = "RASA_TEST_MYSQL"; + + internal static RasaDbContextBase CreateContext(Type contextType) + { + var server = Environment.GetEnvironmentVariable(ServerVariable); + if (string.IsNullOrWhiteSpace(server)) + Assert.Inconclusive( + $"{ServerVariable} is not set. These tests need a MySQL server with the migrations applied; see docs/setup.md."); + + var builder = new MySqlConnectionStringBuilder(server); + using var settings = JsonDocument.Parse(File.ReadAllText( + Path.Combine(AppContext.BaseDirectory, "databasesettings.json"))); + var databases = settings.RootElement.GetProperty("Databases"); + + DatabaseConnectionConfiguration Connection(string name) => new() + { + Host = builder.Server, + Port = builder.Port, + User = builder.UserID, + Password = builder.Password, + Database = databases.GetProperty(name).GetProperty("Database").GetString(), + TimeoutInMilliseconds = 30000 + }; + + var options = Options.Create(new DatabaseConfiguration + { + Provider = "MySql", + Auth = Connection("Auth"), + Char = Connection("Char"), + World = Connection("World") + }); + return (RasaDbContextBase)Activator.CreateInstance( + contextType, + options, + new MySqlDbContextConfigurationService(new MySqlConnectionStringFactory()), + new MySqlDbContextPropertyModifier()); + } + } +} diff --git a/src/Rasa.Test/Database/MySqlLiveDatabaseTests.cs b/src/Rasa.Test/Database/MySqlLiveDatabaseTests.cs new file mode 100644 index 00000000..1618c896 --- /dev/null +++ b/src/Rasa.Test/Database/MySqlLiveDatabaseTests.cs @@ -0,0 +1,190 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Microsoft.EntityFrameworkCore; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Rasa.Test.Database +{ + using Rasa.Context; + using Rasa.Context.Auth; + using Rasa.Context.Char; + using Rasa.Context.World; + using Rasa.Repositories.Auth.Account; + using Rasa.Repositories.Char.Character; + using Rasa.Repositories.Char.CharacterMission; + using Rasa.Repositories.Char.GameAccount; + using Rasa.Services.Passwords; + using Rasa.Services.Random; + using Rasa.Structures.Char; + + /// + /// The MySQL contexts against a real server with every migration applied by + /// `dotnet ef database update`, the way a MySQL deployment gets its schema. The other database + /// tests build the MySQL model offline against a faked server version; these are the ones that + /// find a migration MySQL rejects, seed data that differs from Sqlite's, or a constraint only + /// one provider enforces. See for how they find the server. + /// + [TestClass] + [TestCategory(LiveMySql.Category)] + [DoNotParallelize] + public class MySqlLiveDatabaseTests + { + [TestMethod] + [DataRow(typeof(MySqlAuthContext))] + [DataRow(typeof(MySqlCharContext))] + [DataRow(typeof(MySqlWorldContext))] + public void EveryMigrationIsAppliedAndNoneIsPending(Type contextType) + { + using var context = LiveMySql.CreateContext(contextType); + + CollectionAssert.AreEqual( + context.Database.GetMigrations().ToArray(), + context.Database.GetAppliedMigrations().ToArray()); + Assert.IsFalse(context.Database.GetPendingMigrations().Any()); + } + + /// + /// Every table holds as many rows after MySQL's migrations as after Sqlite's: the seed data + /// and the data migrations are written per provider, and this is what notices when one of + /// them drifts. The tests here that write rows remove them again. + /// + [TestMethod] + [DataRow(typeof(MySqlAuthContext), typeof(SqliteAuthContext))] + [DataRow(typeof(MySqlCharContext), typeof(SqliteCharContext))] + [DataRow(typeof(MySqlWorldContext), typeof(SqliteWorldContext))] + public void MigratedTablesHoldTheSameRowsAsSqlite(Type mySqlType, Type sqliteType) + { + using var mySql = LiveMySql.CreateContext(mySqlType); + var path = Path.Combine(AppContext.BaseDirectory, "TestDatabases", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(path); + try + { + using var sqlite = PersistenceIntegrationTests.CreateContext(sqliteType, Path.Combine(path, "database")); + sqlite.Database.Migrate(); + + var tables = mySql.Model.GetEntityTypes() + .Select(entity => entity.GetTableName()) + .Where(table => table != null) + .Distinct() + .OrderBy(table => table, StringComparer.Ordinal) + .ToArray(); + Assert.IsTrue(tables.Length > 0); + + var differences = new List(); + foreach (var table in tables) + { + var expected = CountRows(sqlite, table); + var actual = CountRows(mySql, table); + if (expected != actual) + differences.Add($"{table}: Sqlite {expected}, MySQL {actual}"); + } + + Assert.AreEqual(0, differences.Count, string.Join(Environment.NewLine, differences)); + } + finally + { + Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); + Directory.Delete(path, true); + } + } + + [TestMethod] + public void AuthAccountsRoundTripAndUsernamesAreUniqueWithoutCase() + { + var name = "Live" + Guid.NewGuid().ToString("N")[..12]; + using var context = (AuthContext)LiveMySql.CreateContext(typeof(MySqlAuthContext)); + using var random = new RandomNumberService(); + var accounts = new AuthAccountRepository(context, random, new HashSettings()); + try + { + accounts.Create(name + "@example.invalid", name, "password123"); + + using var reopened = (AuthContext)LiveMySql.CreateContext(typeof(MySqlAuthContext)); + var found = new AuthAccountRepository(reopened, random, new HashSettings()) + .FindByUserNameOrEmail(name.ToUpperInvariant(), null); + Assert.IsNotNull(found); + Assert.AreEqual(name, found.Username); + Assert.AreEqual("0.0.0.0", found.LastIp); + Assert.IsFalse(found.Locked); + Assert.IsTrue(accounts.CheckPassword(found, "password123")); + Assert.IsFalse(accounts.CheckPassword(found, "password124")); + + // MySQL's default collation compares without case, so the unique index already + // refuses "Bob" beside "bob"; Sqlite's BINARY collation would store both. + using var duplicate = (AuthContext)LiveMySql.CreateContext(typeof(MySqlAuthContext)); + Assert.ThrowsExactly(() => + new AuthAccountRepository(duplicate, random, new HashSettings()) + .Create("other-" + name + "@example.invalid", name.ToUpperInvariant(), "password123")); + } + finally + { + using var cleanup = LiveMySql.CreateContext(typeof(MySqlAuthContext)); + cleanup.Database.ExecuteSql($"DELETE FROM `account` WHERE username = {name}"); + } + } + + [TestMethod] + public void CharactersAndMissionsRoundTripAndForeignKeysHold() + { + var accountId = 4_000_000_000u + (uint)Random.Shared.Next(1, 100_000_000); + var name = "Live" + accountId; + try + { + uint characterId; + using (var context = (CharContext)LiveMySql.CreateContext(typeof(MySqlCharContext))) + { + var accounts = new GameAccountRepository(context); + accounts.CreateOrUpdate(accountId, name, name + "@example.invalid"); + var character = new CharacterRepository(context) + .Create(accounts.Get(accountId), 1, name, 1, 1.0, 0); + Assert.IsNotNull(character, "The character was not created; the error is in the log above."); + characterId = character.Id; + new CharacterMissionRepository(context).Add(new CharacterMissionEntry(characterId, 429, 2)); + } + + using (var reopened = (CharContext)LiveMySql.CreateContext(typeof(MySqlCharContext))) + { + var character = new CharacterRepository(reopened).GetByAccountId(accountId, 1); + Assert.IsNotNull(character); + Assert.AreEqual(characterId, character.Id); + Assert.AreEqual(name, character.GameAccount.Name); + Assert.AreEqual(string.Empty, character.GameAccount.FamilyName); + var mission = new CharacterMissionRepository(reopened).GetByCharacterAndMission(characterId, 429); + Assert.IsNotNull(mission); + Assert.AreEqual(2u, mission.MissionState); + Assert.AreEqual(32, mission.AssignmentId.Length); + + // InnoDB enforces both keys: an account can't go while it has a character + // (Restrict), and a character takes its missions with it (Cascade). + reopened.Remove(reopened.GameAccountEntries.Single(e => e.Id == accountId)); + Assert.ThrowsExactly(() => reopened.SaveChanges()); + } + + using (var context = (CharContext)LiveMySql.CreateContext(typeof(MySqlCharContext))) + { + context.Database.ExecuteSql($"DELETE FROM `character` WHERE id = {characterId}"); + Assert.AreEqual(0, context.CharacterMissionEntries.Count(e => e.CharacterId == characterId)); + } + } + finally + { + using var cleanup = LiveMySql.CreateContext(typeof(MySqlCharContext)); + cleanup.Database.ExecuteSql($"DELETE FROM `character` WHERE account_id = {accountId}"); + cleanup.Database.ExecuteSql($"DELETE FROM `account` WHERE id = {accountId}"); + } + } + + // Backticks quote an identifier in both MySQL and Sqlite. An identifier can't be a + // parameter; the table names come from the model. + private static int CountRows(RasaDbContextBase context, string table) => + context.Database.SqlQueryRaw("SELECT COUNT(*) AS Value FROM `" + table + "`").Single(); + + private sealed class HashSettings : IPasswordHashSettings + { + public string Pepper => string.Empty; + public int Iterations => PasswordHasher.MinimumIterations; + } + } +} From e65b0d737b5109c1352316a3575fe078532c4e58 Mon Sep 17 00:00:00 2001 From: Steven Cady Date: Mon, 5 Oct 2026 09:05:36 -0500 Subject: [PATCH 08/10] Address the workflow review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Release waits for CI on the commit (a /release usually comes right after a merge), takes only development push runs, also requires the container smoke test when it ran, and reacts 🚀 or 👎 on the comment for the actual outcome instead of on dispatch. - Each push to development gets its own CI concurrency group, so a pending run is never replaced and every commit stays releasable. - Prune skips only on a 404, and fails on any other API error. - The smoke test spots a crash (restart: always hides it as a restart), matches UDP ports exactly, watches Rasa.NET.sln and .config, and keeps the advisory scan from failing the job or saving a cache on PRs. - Dependabot's docker entry matches every image in the Dockerfile. - Docs: SDK bumps arrive as one PR; released images are amd64 only. - Restore the line continuations in dotnet.yml that had collapsed to spaces. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AxABTHjs6nJrsXVCTnmMo7 --- .github/dependabot.yml | 3 +- .github/workflows/container.yml | 29 ++++++++++------ .github/workflows/dotnet.yml | 21 +++++++---- .github/workflows/prune-container.yml | 7 ++-- .github/workflows/release-container.yml | 44 +++++++++++++++++++++--- .github/workflows/release-on-comment.yml | 4 +-- docs/docker_setup.md | 2 +- docs/setup.md | 7 ++-- 8 files changed, 86 insertions(+), 31 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 242d4ce6..594ab1d2 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -22,7 +22,8 @@ updates: - package-ecosystem: docker directory: / multi-ecosystem-group: dotnet-sdk - patterns: [ "dotnet/*" ] + # The Dockerfile's only images are the SDK and runtime. + patterns: [ "*" ] ignore: - dependency-name: "dotnet/*" versions: [ ">= 11" ] diff --git a/.github/workflows/container.yml b/.github/workflows/container.yml index 58b0ac69..f36800fc 100644 --- a/.github/workflows/container.yml +++ b/.github/workflows/container.yml @@ -5,9 +5,9 @@ name: Container on: push: branches: [ development ] - paths: [ Dockerfile, .dockerignore, docker-compose.yml, global.json, Directory.Packages.props, 'navmesh/**', 'src/**', .github/workflows/container.yml ] + paths: [ Dockerfile, .dockerignore, docker-compose.yml, global.json, Directory.Packages.props, Rasa.NET.sln, '.config/**', 'navmesh/**', 'src/**', .github/workflows/container.yml ] pull_request: - paths: [ Dockerfile, .dockerignore, docker-compose.yml, global.json, Directory.Packages.props, 'navmesh/**', 'src/**', .github/workflows/container.yml ] + paths: [ Dockerfile, .dockerignore, docker-compose.yml, global.json, Directory.Packages.props, Rasa.NET.sln, '.config/**', 'navmesh/**', 'src/**', .github/workflows/container.yml ] permissions: contents: read @@ -27,6 +27,7 @@ jobs: # The layer cache is read on every run and written only from development, for the same # reason as the NuGet cache in dotnet.yml: a PR's cache can only be read by that PR. - name: Build the image + id: image uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 with: context: . @@ -50,10 +51,13 @@ jobs: if ready auth 'Listening for clients on port' && ready game 'Server ready!'; then exit 0 fi - if [ -n "$(docker compose ps --status exited --quiet)" ]; then - echo "::error::A service exited during startup." - exit 1 - fi + # Both services have restart: always, so a crash shows as a restart, not an exit. + for id in $(docker compose ps --all --quiet); do + if [ "$(docker inspect -f '{{.RestartCount}} {{.State.Status}}' "$id")" != "0 running" ]; then + echo "::error::A service crashed or stopped during startup." + exit 1 + fi + done sleep 2 done echo "::error::The servers weren't ready within 4 minutes." @@ -75,7 +79,7 @@ jobs: fi done for port in $(jq -r --arg s "$service" '.services[$s].ports[]? | select(.protocol == "udp") | .target' <<< "$config"); do - if docker compose logs --no-color "$service" | grep -q "UDP port $port"; then + if docker compose logs --no-color "$service" | grep -qE "UDP port ${port}([^0-9]|$)"; then echo "$service listens on udp/$port" else echo "::error::$service never logged listening on udp/$port, which docker-compose.yml maps." @@ -90,14 +94,17 @@ jobs: - name: Service logs if: always() run: docker compose logs --no-color - # Advisory: reports known HIGH and CRITICAL vulnerabilities without failing the job. Pinned by - # commit: the trivy-action tags were overwritten with a credential stealer in March 2026 - # (CVE-2026-33634). + # Advisory: reports known HIGH and CRITICAL vulnerabilities and never fails the job, not even + # when the vulnerability database can't be downloaded. Its cache is off so PR runs save none. + # Pinned by commit: the trivy-action tags were overwritten with a credential stealer in March + # 2026 (CVE-2026-33634). - name: Scan the image - if: always() + if: always() && steps.image.outcome == 'success' + continue-on-error: true uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 with: image-ref: rasa_net severity: HIGH,CRITICAL ignore-unfixed: true exit-code: '0' + cache: 'false' diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 9ff375f4..fccdb16e 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -9,9 +9,11 @@ permissions: contents: read # A new push to a PR cancels that PR's older run. Runs on development are never cancelled: a -# cancelled run saves no NuGet cache and leaves no timings for the next run's test plan. +# cancelled run saves no NuGet cache, leaves no timings for the next run's test plan, and leaves +# its commit unreleasable. Each push gets a group of its own, since a group holds only one +# pending run and replaces it with the next. concurrency: - group: ci-${{ github.ref }} + group: ci-${{ github.ref }}${{ github.event_name == 'push' && format('-{0}', github.sha) || '' }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} env: @@ -100,7 +102,8 @@ jobs: HEAD: ${{ github.head_ref }} run: | last_green() { - gh run list -R "$REPO" --workflow dotnet.yml --status success --limit 3 --json databaseId --jq '.[].databaseId' "$@" 2>/dev/null || true + gh run list -R "$REPO" --workflow dotnet.yml --status success --limit 3 \ + --json databaseId --jq '.[].databaseId' "$@" 2>/dev/null || true } runs=$(last_green --branch development --event push) if [ -z "$runs" ] && [ -n "$HEAD" ]; then @@ -108,12 +111,14 @@ jobs: fi for run in $runs; do echo "Timings from run $run." - gh run download "$run" -R "$REPO" --pattern 'test-results-*' --dir "timings/$run" || echo "::notice::No timings from run $run." + gh run download "$run" -R "$REPO" --pattern 'test-results-*' --dir "timings/$run" \ + || echo "::notice::No timings from run $run." done - name: Plan test lanes id: plan run: | - dotnet run .github/scripts/TestShards.cs -- src/Rasa.Test/bin/Debug/net10.0/Rasa.Test.dll $((RUNNERS * LANES)) src/Rasa.Test/bin/Debug/net10.0/lanes timings + dotnet run .github/scripts/TestShards.cs -- \ + src/Rasa.Test/bin/Debug/net10.0/Rasa.Test.dll $((RUNNERS * LANES)) src/Rasa.Test/bin/Debug/net10.0/lanes timings echo "runners=$(jq -cn --argjson n "$RUNNERS" '[range($n)]')" >> "$GITHUB_OUTPUT" # One archive so file modes survive (upload-artifact doesn't keep them). - name: Package test build @@ -158,7 +163,11 @@ jobs: dotnet test "$bin/Rasa.Test.dll" --list-tests > /dev/null pids=() for lane in $(seq $((RUNNER * LANES)) $((RUNNER * LANES + LANES - 1))); do - dotnet test "$bin/Rasa.Test.dll" --filter "$(cat "$bin/lanes/lane-$lane.filter")" --blame-hang-timeout 10m --logger "trx;LogFileName=lane-$lane.trx" --results-directory "TestResults/lane-$lane" > "lane-$lane.log" 2>&1 & + dotnet test "$bin/Rasa.Test.dll" \ + --filter "$(cat "$bin/lanes/lane-$lane.filter")" \ + --blame-hang-timeout 10m \ + --logger "trx;LogFileName=lane-$lane.trx" \ + --results-directory "TestResults/lane-$lane" > "lane-$lane.log" 2>&1 & pids+=("$lane:$!") done status=0 diff --git a/.github/workflows/prune-container.yml b/.github/workflows/prune-container.yml index 257fe428..334538f5 100644 --- a/.github/workflows/prune-container.yml +++ b/.github/workflows/prune-container.yml @@ -28,10 +28,13 @@ jobs: run: | kind=users [ "$(gh api "repos/$REPO" --jq .owner.type)" = Organization ] && kind=orgs - if gh api "$kind/$OWNER/packages/container/rasa.net" > /dev/null 2>&1; then + if out=$(gh api "$kind/$OWNER/packages/container/rasa.net" 2>&1); then echo "exists=true" >> "$GITHUB_OUTPUT" - else + elif grep -q 'HTTP 404' <<< "$out"; then echo "::notice::No rasa.net container package yet; nothing to prune." + else + echo "$out" + exit 1 fi - if: steps.package.outputs.exists == 'true' uses: actions/delete-package-versions@e5bc658cc4c965c472efe991f8beea3981499c55 # v5.0.0 diff --git a/.github/workflows/release-container.yml b/.github/workflows/release-container.yml index 4fd59179..3c79e29f 100644 --- a/.github/workflows/release-container.yml +++ b/.github/workflows/release-container.yml @@ -11,6 +11,9 @@ on: pull_request: description: The PR the /release comment was on required: false + comment_id: + description: The /release comment, which gets a reaction for the outcome + required: false permissions: contents: read @@ -22,24 +25,44 @@ concurrency: jobs: release: runs-on: ubuntu-24.04 - timeout-minutes: 30 + timeout-minutes: 60 permissions: contents: read actions: read + issues: write packages: write steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: development - # Only a commit whose CI passed is released. - - name: Check CI passed on this commit + # Only a commit whose CI passed on development is released. A /release usually comes right + # after a merge, while that commit's CI is still running, so this waits for it to finish. The + # container smoke test only runs when the image's inputs changed; if it ran, it must pass too. + - name: Wait for CI on this commit env: GH_TOKEN: ${{ github.token }} REPO: ${{ github.repository }} run: | sha=$(git rev-parse HEAD) - if [ -z "$(gh run list -R "$REPO" --workflow dotnet.yml --commit "$sha" --status success --json databaseId --jq '.[].databaseId')" ]; then - echo "::error::CI hasn't passed on development's HEAD ($sha) yet; release after it does." + latest() { + gh run list -R "$REPO" --workflow "$1" --commit "$sha" --event push --branch development \ + --limit 1 --json status,conclusion --jq '.[0] | "\(.status) \(.conclusion)"' + } + for _ in $(seq 80); do + ci=$(latest dotnet.yml) + container=$(latest container.yml) + if [ "${ci%% *}" = completed ] && { [ -z "$container" ] || [ "${container%% *}" = completed ]; }; then + break + fi + sleep 30 + done + echo "dotnet.yml: ${ci:-no run}; container.yml: ${container:-no run}" + if [ "$ci" != "completed success" ]; then + echo "::error::CI on development's HEAD ($sha) didn't pass (${ci:-no run}); nothing was released." + exit 1 + fi + if [ -n "$container" ] && [ "$container" != "completed success" ]; then + echo "::error::The container smoke test on $sha didn't pass ($container); nothing was released." exit 1 fi # GHCR names must be lowercase. The dated tag is what gets kept and pruned; latest and @@ -87,3 +110,14 @@ jobs: if [ -n "$REQUESTED_BY" ]; then echo "Requested by @$REQUESTED_BY${PR:+ on #$PR}." >> "$GITHUB_STEP_SUMMARY" fi + - name: React to the /release comment + if: always() && inputs.comment_id != '' + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + COMMENT_ID: ${{ inputs.comment_id }} + OUTCOME: ${{ job.status }} + run: | + content=-1 + [ "$OUTCOME" = success ] && content=rocket + gh api --method POST "repos/$REPO/issues/comments/$COMMENT_ID/reactions" -f content="$content" > /dev/null diff --git a/.github/workflows/release-on-comment.yml b/.github/workflows/release-on-comment.yml index 65835810..98b0877c 100644 --- a/.github/workflows/release-on-comment.yml +++ b/.github/workflows/release-on-comment.yml @@ -52,7 +52,7 @@ jobs: exit 0 fi + # 👀 now; the release run adds 🚀 or 👎 when it finishes. react eyes gh workflow run release-container.yml -R "$REPO" --ref development \ - -f requested_by="$COMMENTER" -f pull_request="$PR" - react rocket + -f requested_by="$COMMENTER" -f pull_request="$PR" -f comment_id="$COMMENT_ID" diff --git a/docs/docker_setup.md b/docs/docker_setup.md index 114efa55..5f87b44a 100644 --- a/docs/docker_setup.md +++ b/docs/docker_setup.md @@ -111,7 +111,7 @@ docker tag ghcr.io/infiniterasa/rasa.net:latest rasa_net docker compose up --no-build ``` -Each release also has a dated tag (`-`); the 14 newest +Released images are built for `linux/amd64` only. Each release also has a dated tag (`-`); the 14 newest are kept. ## Start Server diff --git a/docs/setup.md b/docs/setup.md index 96209e5a..6a8a4983 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -39,9 +39,10 @@ The SDK version lives in two places: `global.json` and the `sdk` build stage in the `Dockerfile`. NuGet versions all live in `Directory.Packages.props`, and the `dotnet-ef` tool in `.config/dotnet-tools.json` follows the EF Core packages. Change every copy in the same commit; `PlatformCompatibilityTests` fails when -they disagree. Dependabot opens grouped update PRs (`.github/dependabot.yml`); -an SDK PR from Dependabot changes `global.json` only, so push the matching -`Dockerfile` tag to that PR's branch before merging it. EF Core 10 and Pomelo +they disagree. Dependabot opens grouped update PRs (`.github/dependabot.yml`), +and moves `global.json` and the `Dockerfile` together in one SDK PR. If an SDK +PR changes only one of them (for example because the image isn't published +yet), push the other change to that PR's branch before merging it. EF Core 10 and Pomelo updates are ignored until the project chooses a MySQL provider for EF Core 10. The supported portable deployment identifiers are `win-x64`, `osx-x64` and `linux-x64`. These preserve the Windows, macOS and Linux x64 deployment families, not support for the obsolete operating-system versions named by the old .NET 5 identifiers. Use an operating system supported by .NET 10. From ef08d215b6ceb68d7e9fa4a85df7c9a499e2296b Mon Sep 17 00:00:00 2001 From: Steven Cady Date: Mon, 5 Oct 2026 09:19:13 -0500 Subject: [PATCH 09/10] Address the code review - The Dockerfile model reads FROM past --platform and other flags, keeps each stage's WORKDIR (a stage built FROM another inherits it), accepts a stage index in COPY --from, and rejects a copy from the current stage. - The SDK guard strips @sha256 digests and accepts a runtime image pinned to a patch (runtime:10.0.12) as well as the floating line. - A guard that the build stage gets global.json, Directory.Packages.props and the tool manifest before it restores. - Live MySQL tests also read databasesettings.env.json for database names. - docs/setup.md: no hardcoded SDK or EF versions, the one-line recovery for a MySQL World database whose build stopped at SeedWorldContent before the reconstruction_note fix, and a warning to run the live tests only against a throwaway server. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AxABTHjs6nJrsXVCTnmMo7 --- docs/setup.md | 20 +++++++--- .../Compatibility/ContainerLayoutModels.cs | 30 ++++++++------ .../ContainerLayoutModelsTests.cs | 39 +++++++++++++++++++ .../PlatformCompatibilityTests.cs | 10 +++++ src/Rasa.Test/Compatibility/RepositoryPins.cs | 12 ++++-- .../Compatibility/RepositoryPinsTests.cs | 12 ++++++ src/Rasa.Test/Database/LiveMySql.cs | 18 +++++---- 7 files changed, 113 insertions(+), 28 deletions(-) diff --git a/docs/setup.md b/docs/setup.md index 6a8a4983..76c97cec 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -10,7 +10,7 @@ This guide will help you install and setup the required tools to run Rasa.NET. T ## Download, install, and setup the required tooling The following tools are required to setup, build, and run Rasa.NET: -- .NET SDK 10.0.401 (pinned in the repository's `global.json`) +- The .NET 10 SDK version pinned in the repository's `global.json` - Optional: a Visual Studio release that supports this .NET 10 SDK - Database System, either: - MySQL Server and Workbench or @@ -30,8 +30,8 @@ All solution projects target .NET 10, including `Rasa.Missions`. Install the exact SDK selected by `global.json`; SDK roll-forward is disabled so local builds, CI and Docker use the same version. -- [Download the .NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) and install version **10.0.401**. The SDK includes the runtime. -- From the repository root, run `dotnet --version` and verify `10.0.401`. +- [Download the .NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) and install the exact version in `global.json`'s `sdk.version`. The SDK includes the runtime. +- From the repository root, run `dotnet --version` and verify it prints that version. #### Updating the SDK or packages @@ -190,7 +190,7 @@ To access a MySql server with EF Core, set `Provider` to `MySql`. You need to pr If you want to add additional migrations as part of a feature, see "Creating migrations". ## Working with the databases and EF Core -The databases are kept up to date with EF Core. The compatible package set is EF Core/SQLite/Design **9.0.20** with Pomelo MySQL **9.0.0**, running on .NET 10. Pomelo 9 supports EF Core 9, not EF Core 10; upgrade these providers together. EF Core 9 support ends November 10, 2026, so this dependency choice needs review before that date. MySQL 8.0 and 8.4 are supported by the provider. +The databases are kept up to date with EF Core. The compatible package set is EF Core/SQLite/Design 9 with Pomelo MySQL 9, running on .NET 10; the exact versions are in `Directory.Packages.props`. Pomelo 9 supports EF Core 9, not EF Core 10; upgrade these providers together. EF Core 9 support ends November 10, 2026, so this dependency choice needs review before that date. MySQL 8.0 and 8.4 are supported by the provider. MySQL schema names up to the server's 64-character limit are supported. Rasa preserves Pomelo's migration-lock names for schemas up to 45 characters and uses a deterministic, case-normalized SHA256 lock name for longer schemas. This keeps migration synchronization and history intact without renaming databases. The naming override uses Pomelo 9's protected lock-name hook; revalidate it when upgrading the provider. @@ -238,6 +238,16 @@ To apply any pending migrations, execute the following commands: Explicitly providing the context is required as we have to work with different contexts according to database and provider. +If a MySQL World database was first built before the fix for `reconstruction_note` +(October 2026), its build may have stopped at `SeedWorldContent` with "Data too long +for column 'reconstruction_note'". `ConsolidatedWorldSchema` is then already recorded +as applied with the column too narrow, so re-running the update fails the same way. +Widen the column once, then run the World update again: + +```sql +ALTER TABLE mission_evidence MODIFY reconstruction_note text CHARACTER SET utf8mb4 NOT NULL; +``` + You can also migrate to any specific migration (forward or backward) by passing the migration name or the index/number of the migration as an argument. Obviously, this works with other DbContexts, too: - `dotnet ef database update "MigrationName" --context=MySqlAuthContext` migrates MySqlAuthContext to "MigrationName". @@ -447,7 +457,7 @@ $env:RASA_TEST_MYSQL = "Server=127.0.0.1;Port=3306;User ID=root;Password=rasa-ci dotnet test src\Rasa.Test --no-build --filter TestCategory=MySql ``` -The tests use the database names from `databasesettings.json` and remove the rows they add. Delete `databasesettings.env.json` afterwards, or the servers you run from that build will read it too. +The tests use the database names from `databasesettings.json` (and `databasesettings.env.json`, if it sets them), check row counts against an empty build, and remove the rows they add, so point them only at a throwaway server, never at one with real data. Delete `databasesettings.env.json` afterwards, or the servers you run from that build will read it too. ### Create a game user The authentication server can be used to create a user by running a command in the terminal. The usage is: `create `. Running this command will create a new user in the database that you can use to login with the game client. diff --git a/src/Rasa.Test/Compatibility/ContainerLayoutModels.cs b/src/Rasa.Test/Compatibility/ContainerLayoutModels.cs index 96017ab6..6b049020 100644 --- a/src/Rasa.Test/Compatibility/ContainerLayoutModels.cs +++ b/src/Rasa.Test/Compatibility/ContainerLayoutModels.cs @@ -203,6 +203,7 @@ internal sealed class DockerImageLayout internal string BaseImage { get; } internal string User { get; private set; } = "root"; + internal string WorkingDirectory { get; private set; } = "/"; private DockerImageLayout(string baseImage) { @@ -225,24 +226,28 @@ internal static IReadOnlyList CreateStages(string repositoryR var stageNames = new Dictionary( StringComparer.OrdinalIgnoreCase); DockerImageLayout layout = null; - var workingDirectory = "/"; foreach (var instruction in dockerfile.Instructions) { if (instruction.Name == "FROM") { - var from = SplitArguments(instruction.Arguments); - if (from.Count != 1 && - !(from.Count == 3 && from[1].Equals("AS", StringComparison.OrdinalIgnoreCase))) + // --platform and other flags choose how the base image is pulled, not what + // the stage holds. + var from = SplitArguments(instruction.Arguments) + .Where(field => !field.StartsWith("--", StringComparison.Ordinal)) + .ToArray(); + if (from.Length != 1 && + !(from.Length == 3 && from[1].Equals("AS", StringComparison.OrdinalIgnoreCase))) throw new InvalidDataException($"Unsupported Docker FROM instruction: {instruction.Arguments}"); layout = new DockerImageLayout(from[0]); if (stageNames.TryGetValue(from[0], out var parent)) layout.CopyStage(parent); + // COPY --from takes a stage's name or its index. + stageNames[stages.Count.ToString(System.Globalization.CultureInfo.InvariantCulture)] = layout; stages.Add(layout); - if (from.Count == 3) + if (from.Length == 3) stageNames[from[2]] = layout; - workingDirectory = "/"; continue; } @@ -256,15 +261,15 @@ internal static IReadOnlyList CreateStages(string repositoryR switch (instruction.Name) { case "WORKDIR": - workingDirectory = PosixPath.Resolve( - workingDirectory, + layout.WorkingDirectory = PosixPath.Resolve( + layout.WorkingDirectory, instruction.Arguments); - layout.AddDirectory(workingDirectory); + layout.AddDirectory(layout.WorkingDirectory); break; case "COPY": layout.ApplyCopy( repositoryRoot, - workingDirectory, + layout.WorkingDirectory, instruction.Arguments, dockerIgnore, stageNames); @@ -278,7 +283,7 @@ internal static IReadOnlyList CreateStages(string repositoryR build.Configuration.Equals( "Release", StringComparison.OrdinalIgnoreCase)) - layout.ApplyBuild(workingDirectory, build); + layout.ApplyBuild(layout.WorkingDirectory, build); break; } } @@ -330,7 +335,7 @@ private void ApplyCopy( if (fromStage != null) { - if (!stages.TryGetValue(fromStage, out var stage)) + if (!stages.TryGetValue(fromStage, out var stage) || stage == this) throw new InvalidDataException($"Docker COPY --from names no earlier stage: {arguments}"); ApplyStageCopy(stage, workingDirectory, fields[0], fields[1]); return; @@ -428,6 +433,7 @@ private void CopyStage(DockerImageLayout parent) foreach (var pair in parent._copiedHostFiles) _copiedHostFiles[pair.Key] = pair.Value; User = parent.User; + WorkingDirectory = parent.WorkingDirectory; } private void ApplyBuild( diff --git a/src/Rasa.Test/Compatibility/ContainerLayoutModelsTests.cs b/src/Rasa.Test/Compatibility/ContainerLayoutModelsTests.cs index 030893c7..b37d8ac2 100644 --- a/src/Rasa.Test/Compatibility/ContainerLayoutModelsTests.cs +++ b/src/Rasa.Test/Compatibility/ContainerLayoutModelsTests.cs @@ -172,6 +172,45 @@ USER app Assert.IsTrue(stages[0].ContainsFile("/app/src/App/App.csproj")); } + [TestMethod] + public void StagesAreAddressableByIndexAndInheritTheirParentsWorkingDirectory() + { + using var repository = new DockerRepositoryFixture(); + repository.Write(".dockerignore", "**/bin\n**/obj\n"); + repository.Write("Dockerfile", """ + FROM --platform=linux/amd64 mcr.microsoft.com/dotnet/sdk:10.0.401 AS source + WORKDIR /app + COPY src /app/src + FROM source AS build + RUN dotnet build src/App/App.csproj -c Release + FROM mcr.microsoft.com/dotnet/runtime:10.0 + COPY --from=1 /app/src/App/bin/Release/net10.0 /app + """); + repository.Write("src/App/App.csproj", Project("App")); + + var stages = DockerImageLayout.CreateStages(repository.Root); + + Assert.AreEqual("/app", stages[1].WorkingDirectory); + Assert.IsTrue(stages[1].ContainsFile("/app/src/App/bin/Release/net10.0/App.dll")); + Assert.IsTrue(stages[2].ContainsFile("/app/App.dll")); + Assert.AreEqual("/", stages[2].WorkingDirectory); + } + + [TestMethod] + public void CopyFromTheCurrentStageIsRejected() + { + using var repository = new DockerRepositoryFixture(); + repository.Write(".dockerignore", string.Empty); + repository.Write("Dockerfile", """ + FROM scratch AS build + WORKDIR /app + COPY --from=build /app /copy + """); + + Assert.ThrowsExactly( + () => DockerImageLayout.Create(repository.Root)); + } + [TestMethod] public void CopyFromAnUnknownStageIsRejected() { diff --git a/src/Rasa.Test/Compatibility/PlatformCompatibilityTests.cs b/src/Rasa.Test/Compatibility/PlatformCompatibilityTests.cs index cd4ba6c2..723f50c7 100644 --- a/src/Rasa.Test/Compatibility/PlatformCompatibilityTests.cs +++ b/src/Rasa.Test/Compatibility/PlatformCompatibilityTests.cs @@ -91,6 +91,16 @@ public void DockerImageRunsAsTheRuntimeImagesNonRootUser() StringAssert.StartsWith(image.BaseImage, "mcr.microsoft.com/dotnet/runtime:"); } + [TestMethod] + public void DockerBuildStageHasTheFilesThatPinTheBuild() + { + var build = DockerImageLayout.CreateStages(FindRepositoryRoot())[0]; + + // Without these, the image would restore and build with other versions than CI. + foreach (var file in new[] { "global.json", "Directory.Packages.props", ".config/dotnet-tools.json" }) + Assert.IsTrue(build.ContainsFile("/app/" + file), $"The build stage has no /app/{file}."); + } + [TestMethod] public void DockerServicesRunWhereRequiredConfigurationAndAssetsExist() { diff --git a/src/Rasa.Test/Compatibility/RepositoryPins.cs b/src/Rasa.Test/Compatibility/RepositoryPins.cs index e207d5fb..b6b0cebf 100644 --- a/src/Rasa.Test/Compatibility/RepositoryPins.cs +++ b/src/Rasa.Test/Compatibility/RepositoryPins.cs @@ -23,9 +23,13 @@ internal static string ReadGlobalJsonSdk(string globalJson) internal static IReadOnlyList ReadDockerfileImages(string dockerfile) { + // The image of each FROM, without flags such as --platform or a pinned @sha256 digest. return DockerfileModel.Parse(dockerfile).Instructions .Where(instruction => instruction.Name == "FROM") - .Select(instruction => instruction.Arguments.Split(' ', StringSplitOptions.RemoveEmptyEntries)[0]) + .Select(instruction => instruction.Arguments + .Split(' ', StringSplitOptions.RemoveEmptyEntries) + .First(field => !field.StartsWith("--", StringComparison.Ordinal)) + .Split('@')[0]) .ToArray(); } @@ -46,10 +50,12 @@ internal static IReadOnlyList CheckSdk(string globalJson, string dockerf foreach (var tag in sdkTags.Where(tag => tag != sdk)) problems.Add($"The Dockerfile builds with {SdkImage}:{tag}, but global.json pins {sdk}."); + // The runtime image may float on the line (10.0) or pin a patch (10.0.5). var final = images.LastOrDefault() ?? string.Empty; var sdkLine = string.Join('.', sdk.Split('.').Take(2)); - if (final != $"{RuntimeImage}:{sdkLine}") - problems.Add($"The Dockerfile's final stage is {final}, not {RuntimeImage}:{sdkLine}."); + var runtimeLine = $"{RuntimeImage}:{sdkLine}"; + if (final != runtimeLine && !final.StartsWith(runtimeLine + ".", StringComparison.Ordinal)) + problems.Add($"The Dockerfile's final stage is {final}, not {runtimeLine}."); return problems; } diff --git a/src/Rasa.Test/Compatibility/RepositoryPinsTests.cs b/src/Rasa.Test/Compatibility/RepositoryPinsTests.cs index 0b472b7f..d935cc61 100644 --- a/src/Rasa.Test/Compatibility/RepositoryPinsTests.cs +++ b/src/Rasa.Test/Compatibility/RepositoryPinsTests.cs @@ -49,6 +49,18 @@ public void SdkCheckRejectsAnImageThatShipsTheSdk() StringAssert.Contains(problems[0], "final stage"); } + [TestMethod] + public void SdkCheckReadsImagesPastPlatformFlagsDigestsAndPatchPins() + { + var dockerfile = """ + FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:10.0.401@sha256:0123 AS build + FROM mcr.microsoft.com/dotnet/runtime:10.0.12 AS runtime + """; + + Assert.AreEqual(0, RepositoryPins.CheckSdk(GlobalJson, dockerfile).Count); + Assert.AreEqual(1, RepositoryPins.CheckSdk(GlobalJson, dockerfile.Replace("10.0.12", "10.01")).Count); + } + [TestMethod] public void PackageCheckAcceptsMatchingVersions() { diff --git a/src/Rasa.Test/Database/LiveMySql.cs b/src/Rasa.Test/Database/LiveMySql.cs index 5eedf890..8b4115a8 100644 --- a/src/Rasa.Test/Database/LiveMySql.cs +++ b/src/Rasa.Test/Database/LiveMySql.cs @@ -1,6 +1,5 @@ using System; -using System.IO; -using System.Text.Json; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Options; using Microsoft.VisualStudio.TestTools.UnitTesting; using MySqlConnector; @@ -17,8 +16,8 @@ namespace Rasa.Test.Database /// Contexts on a live MySQL server, for the tests in the "MySql" category. The server comes from /// RASA_TEST_MYSQL, a MySqlConnector connection string without a database /// ("Server=127.0.0.1;Port=3306;User ID=root;Password=..."); the database names are the ones - /// databasesettings.json gives, which is where `dotnet ef database update` applied the - /// migrations. Contexts are configured the way the servers configure them (server version + /// databasesettings.json and databasesettings.env.json give, which is where + /// `dotnet ef database update` applied the migrations. Contexts are configured the way the servers configure them (server version /// detection, the bounded migration lock), not with the offline setup the model tests use. /// /// CI runs this category in its own job against mysql:8.0 and mysql:8.4 and leaves it out of @@ -38,9 +37,12 @@ internal static RasaDbContextBase CreateContext(Type contextType) $"{ServerVariable} is not set. These tests need a MySQL server with the migrations applied; see docs/setup.md."); var builder = new MySqlConnectionStringBuilder(server); - using var settings = JsonDocument.Parse(File.ReadAllText( - Path.Combine(AppContext.BaseDirectory, "databasesettings.json"))); - var databases = settings.RootElement.GetProperty("Databases"); + var databases = new ConfigurationBuilder() + .SetBasePath(AppContext.BaseDirectory) + .AddJsonFile("databasesettings.json", false, false) + .AddJsonFile("databasesettings.env.json", true, false) + .Build() + .GetSection("Databases"); DatabaseConnectionConfiguration Connection(string name) => new() { @@ -48,7 +50,7 @@ internal static RasaDbContextBase CreateContext(Type contextType) Port = builder.Port, User = builder.UserID, Password = builder.Password, - Database = databases.GetProperty(name).GetProperty("Database").GetString(), + Database = databases[$"{name}:Database"], TimeoutInMilliseconds = 30000 }; From 8926077c73ebf242ad007d2c8748cd0ea729a7bb Mon Sep 17 00:00:00 2001 From: Steven Cady Date: Mon, 5 Oct 2026 09:17:09 -0500 Subject: [PATCH 10/10] Let a disposed Wilderness harness's world be collected A Wilderness harness removed the creatures it spawned but not its map's dynamic objects. EntityManager outlives the harness, so the Logos (and other objects) it registered kept the map channel, and with it the harness's whole world, reachable for the rest of the test run (gcroot: EntityManager -> DynamicObjects -> Logos -> MapChannel). Dispose now removes the objects on its map that it added, and RuntimeHarnessRetentionTests checks, with weak references and a forced full GC, that a disposed Wilderness or Bootcamp harness leaves its map channel, map channel manager and mission application collectable. The Wilderness guard fails without the fix. Also drops an unused navmesh Lazy that TestNavMeshes replaced. Refs #132. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AxABTHjs6nJrsXVCTnmMo7 --- .../Missions/RuntimeHarnessRetentionTests.cs | 65 +++++++++++++++++++ .../WildernessRuntimeTestHarness.cs | 15 ++--- 2 files changed, 72 insertions(+), 8 deletions(-) create mode 100644 src/Rasa.Test/Missions/RuntimeHarnessRetentionTests.cs diff --git a/src/Rasa.Test/Missions/RuntimeHarnessRetentionTests.cs b/src/Rasa.Test/Missions/RuntimeHarnessRetentionTests.cs new file mode 100644 index 00000000..50e203ba --- /dev/null +++ b/src/Rasa.Test/Missions/RuntimeHarnessRetentionTests.cs @@ -0,0 +1,65 @@ +using System; +using System.Runtime.CompilerServices; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Rasa.Test.Missions.Wilderness; + +namespace Rasa.Test.Missions +{ + /// + /// A runtime harness builds a whole world: a map channel, its managers, creatures and a + /// navmesh query. Once it is disposed nothing static may keep that world reachable, or every + /// test that builds one adds its world to the test host for the rest of the run (#132: the + /// suite in one process grew past 12 GB). + /// + [TestClass] + public class RuntimeHarnessRetentionTests + { + [TestMethod] + public void DisposedWildernessHarnessLeavesItsWorldCollectable() + { + var world = CreateAndDisposeWildernessHarness(); + + AssertCollected(world.Map, "map channel"); + AssertCollected(world.Maps, "map channel manager"); + AssertCollected(world.Manager, "mission application"); + } + + [TestMethod] + public void DisposedBootcampHarnessLeavesItsWorldCollectable() + { + var world = CreateAndDisposeBootcampHarness(); + + AssertCollected(world.Map, "map channel"); + AssertCollected(world.Maps, "map channel manager"); + } + + // Not inlined, so no local of the caller's frame holds the harness or its world. + [MethodImpl(MethodImplOptions.NoInlining)] + private static (WeakReference Map, WeakReference Maps, WeakReference Manager) CreateAndDisposeWildernessHarness() + { + using var harness = WildernessRuntimeTestHarness.Create(); + harness.SpawnWorld(); + for (var tick = 0; tick < 8; tick++) + harness.Tick(); + return (new WeakReference(harness.Map), new WeakReference(harness.Maps), + new WeakReference(harness.Manager)); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static (WeakReference Map, WeakReference Maps) CreateAndDisposeBootcampHarness() + { + using var harness = BootcampRuntimeTestHarness.Create(useWorldContent: true); + return (new WeakReference(harness.BootcampMap), new WeakReference(harness.Maps)); + } + + private static void AssertCollected(WeakReference reference, string what) + { + for (var attempt = 0; attempt < 3 && reference.IsAlive; attempt++) + { + GC.Collect(2, GCCollectionMode.Forced, blocking: true, compacting: true); + GC.WaitForPendingFinalizers(); + } + Assert.IsFalse(reference.IsAlive, $"A disposed harness's {what} is still reachable."); + } + } +} diff --git a/src/Rasa.Test/Missions/Wilderness/WildernessRuntimeTestHarness.cs b/src/Rasa.Test/Missions/Wilderness/WildernessRuntimeTestHarness.cs index c7bae06f..f3535e49 100644 --- a/src/Rasa.Test/Missions/Wilderness/WildernessRuntimeTestHarness.cs +++ b/src/Rasa.Test/Missions/Wilderness/WildernessRuntimeTestHarness.cs @@ -29,16 +29,9 @@ internal sealed class WildernessRuntimeTestHarness : IGameUnitOfWorkFactory, IDi private readonly string _directory; private readonly List _restoreServices = new(); private readonly HashSet _originalCreatures = EntityManager.Instance.Creatures.Keys.ToHashSet(); + private readonly HashSet _originalObjects = EntityManager.Instance.DynamicObjects.Keys.ToHashSet(); private bool _disposed; - /// - /// The Wilderness navmesh, read once for the test run: read for every harness, each - /// one's stayed reachable after Dispose, about 50 MB a test. A harness has its own - /// NavMeshQuery over this one's mesh - a query is not thread safe, the mesh is only read. - /// - private static readonly Lazy WildernessNavMesh = new(() => new NavMeshQuery(NavMeshFile.Read( - NavMeshFile.PathFor(Path.Combine(RepositoryRoot(), "navmesh"), "adv_foreas_concordia_wilderness")))); - private WildernessRuntimeTestHarness() { _directory = Path.Combine(AppContext.BaseDirectory, "TestDatabases", Guid.NewGuid().ToString("N")); @@ -291,6 +284,12 @@ public void Dispose() .Where(creature => !_originalCreatures.Contains(creature.EntityId) && Map != null && ReferenceEquals(creature.RuntimeMapChannel, Map)).ToArray()) CellManager.Instance.RemoveCreatureFromWorld(Map, creature); + // The map's objects too: EntityManager outlives the harness, and its registered + // logos, teleporters and other objects each hold this map and so its whole world. + foreach (var dynamicObject in EntityManager.Instance.DynamicObjects.Values + .Where(dynamicObject => !_originalObjects.Contains(dynamicObject.EntityId) && + Map != null && ReferenceEquals(dynamicObject.RuntimeMapChannel, Map)).ToArray()) + CellManager.Instance.RemoveFromWorld(Map, dynamicObject); foreach (var restore in _restoreServices.AsEnumerable().Reverse()) restore(); World?.Dispose();