Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# 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
# The Dockerfile's only images are the SDK and runtime.
patterns: [ "*" ]
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: [ "*" ]
34 changes: 29 additions & 5 deletions .github/scripts/TestShards.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -26,6 +29,8 @@
return 2;
}

string[] ExcludedCategories = ["MySql"];

var assemblyPath = args[0];
var laneCount = int.Parse(args[1]);
var outputDir = args[2];
Expand All @@ -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}");
Expand Down Expand Up @@ -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);
}

Expand All @@ -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<string, Dictionary<string, int>> 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<string, Dictionary<string, int>> FindTests(string path, string[] excluded)
{
using var stream = File.OpenRead(path);
using var pe = new PEReader(stream);
Expand All @@ -122,15 +130,17 @@ static Dictionary<string, Dictionary<string, int>> 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<string, int>(StringComparer.Ordinal);
foreach (var methodHandle in type.GetMethods())
{
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"));
Expand All @@ -146,6 +156,20 @@ static Dictionary<string, Dictionary<string, int>> 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<string> 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)
Expand Down
110 changes: 110 additions & 0 deletions .github/workflows/container.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
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, Rasa.NET.sln, '.config/**', 'navmesh/**', 'src/**', .github/workflows/container.yml ]
pull_request:
paths: [ Dockerfile, .dockerignore, docker-compose.yml, global.json, Directory.Packages.props, Rasa.NET.sln, '.config/**', '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
id: 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
# 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."
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 -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."
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 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() && 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'
Loading
Loading