Files
KyuubiYoru e3b687c903
quality-gate / quality (push) Successful in 2m35s
quality-gate / container (push) Successful in 1m38s
immutable-release / release (push) Successful in 6m26s
fix(release): keep publication off GitHub-only actions (#1)
upload-artifact@v4 speaks the v2 artifacts API, which this Gitea answers
only at its v3-era shape, so the sixth release attempt cleared every gate
and then aborted at "Preserve verified candidate artifacts" with the GHES
compatibility error. Move the step to v3.2.1, the commit the upstream v3
tag resolves to, which keeps name, path, if-no-files-found and
retention-days unchanged. The step also becomes continue-on-error: it is a
pre-publish debugging backstop, and publish-release.sh attaches the same
directory as Gitea release assets, so losing it must never cost a release.

Replace cosign-installer with the direct fetch the scanner already uses.
The action issues no API call on this path, but it is a composite action
resting on envsubst and a resolved runner.arch, neither of which this
runner has exercised. Asked for the version it bootstraps, it downloads
exactly cosign-linux-amd64 from the v3.0.6 release, compares it against
c956e5df..., and exits; that digest matches the release checksums file, so
fetching the asset directly verifies identically with nothing unproven
left before the one-way publication gate. The binary joins the PATH that
publish-release.sh already resolves dotnet through.
2026-08-22 22:10:00 +02:00

340 lines
21 KiB
C#

using System.Reflection;
using System.Text.Json;
using System.Xml.Linq;
using FinalFactory.Rendezvous.Client;
using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.Operations;
namespace FinalFactory.Rendezvous.Tests.Release;
public sealed class ReleaseCompatibilityTests
{
[Fact]
public void CentralVersionsRuntimeWindowAndPublishedMatrixStayAligned()
{
string root = FindRepositoryRoot();
XDocument versions = XDocument.Load(Path.Combine(root, "eng/Versions.props"));
string releaseVersion = Property(versions, "RendezvousVersion");
int releaseMajor = int.Parse(Property(versions, "RendezvousMajorVersion"), System.Globalization.CultureInfo.InvariantCulture);
string[] numericVersion = releaseVersion.Split(['-', '+'], 2)[0].Split('.');
Assert.Equal(releaseMajor, int.Parse(numericVersion[0], System.Globalization.CultureInfo.InvariantCulture));
Assert.Equal(Property(versions, "RendezvousMinorVersion"), numericVersion[1]);
Assert.Equal(Property(versions, "RendezvousPatchVersion"), numericVersion[2]);
int httpVersion = int.Parse(Property(versions, "HttpContractVersion"), System.Globalization.CultureInfo.InvariantCulture);
int udpVersion = int.Parse(Property(versions, "UdpContractVersion"), System.Globalization.CultureInfo.InvariantCulture);
int ticketVersion = int.Parse(Property(versions, "ConnectionTicketFormatVersion"), System.Globalization.CultureInfo.InvariantCulture);
Assert.Equal(releaseVersion, typeof(RendezvousPublisherClient).Assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()!.InformationalVersion.Split('+')[0]);
Assert.Equal(releaseVersion, typeof(ContractLimits).Assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()!.InformationalVersion.Split('+')[0]);
Assert.Equal(ContractLimits.ContractVersion, httpVersion);
OperatorCompatibilityResponse runtime = ReleaseCompatibility.CreateResponse();
Assert.Equal(releaseVersion, runtime.ServerVersion);
Assert.Equal(Property(versions, "MinimumClientVersion"), runtime.MinimumClientVersion);
Assert.Equal(int.Parse(Property(versions, "MaximumClientMajorVersion"), System.Globalization.CultureInfo.InvariantCulture), runtime.MaximumClientMajorVersion);
Assert.Equal([httpVersion], runtime.HttpContractVersions);
Assert.Equal([udpVersion], runtime.UdpContractVersions);
Assert.Equal([ticketVersion], runtime.ConnectionTicketFormatVersions);
Assert.Equal(int.Parse(Property(versions, "LiteNetLibMajorVersion"), System.Globalization.CultureInfo.InvariantCulture), runtime.LiteNetLibMajorVersion);
Assert.Equal("exact-per-tenant", runtime.GameplayProtocolCompatibility);
using JsonDocument matrix = JsonDocument.Parse(File.ReadAllText(Path.Combine(root, "docs/releases/compatibility.json")));
JsonElement document = matrix.RootElement;
Assert.Equal(releaseVersion, document.GetProperty("release").GetString());
Assert.Equal(releaseVersion, document.GetProperty("packages").GetProperty("FinalFactory.Rendezvous.Client").GetString());
Assert.Equal(releaseVersion, document.GetProperty("packages").GetProperty("FinalFactory.Rendezvous.Contracts").GetString());
Assert.Equal(runtime.MinimumClientVersion, document.GetProperty("server").GetProperty("minimumClientVersion").GetString());
Assert.Equal(runtime.MaximumClientMajorVersion, document.GetProperty("server").GetProperty("maximumClientMajorVersion").GetInt32());
Assert.Equal(httpVersion, Assert.Single(document.GetProperty("contracts").GetProperty("http").EnumerateArray()).GetInt32());
Assert.Equal(udpVersion, Assert.Single(document.GetProperty("contracts").GetProperty("udp").EnumerateArray()).GetInt32());
Assert.Equal(ticketVersion, Assert.Single(document.GetProperty("contracts").GetProperty("connectionTicket").EnumerateArray()).GetInt32());
Assert.Equal(runtime.GameplayProtocolCompatibility, document.GetProperty("contracts").GetProperty("gameplay").GetString());
Assert.Equal("LiteNetLib", document.GetProperty("transport").GetProperty("package").GetString());
Assert.Equal(Property(versions, "LiteNetLibVersion"), document.GetProperty("transport").GetProperty("version").GetString());
Assert.Equal(runtime.LiteNetLibMajorVersion, document.GetProperty("transport").GetProperty("major").GetInt32());
foreach ((string consumer, string fixture) in new[]
{
("SpaceGame", "spacegame/SpaceGame.Rendezvous.Consumer.csproj"),
("Unscouted", "unscouted/Unscouted.Rendezvous.Consumer.csproj"),
})
{
XDocument fixtureProject = XDocument.Load(Path.Combine(root, "tests/consumers", fixture));
Assert.Equal(
fixtureProject.Descendants("TargetFramework").Single().Value,
document.GetProperty("consumers").GetProperty(consumer).GetString());
}
string snapshots = Path.Combine(root, $"tests/FinalFactory.Rendezvous.Tests/TestData/Contracts/v{releaseMajor}");
Assert.True(File.Exists(Path.Combine(snapshots, "client-public-api.txt")));
Assert.True(File.Exists(Path.Combine(snapshots, "contracts-public-api.txt")));
Assert.True(File.Exists(Path.Combine(root, $"docs/api/rendezvous-v{httpVersion}.json")));
}
[Fact]
public void ReleaseDefinitionIsImmutableTagOnlyAndDocumentsRequiredNotes()
{
string root = FindRepositoryRoot();
XDocument versions = XDocument.Load(Path.Combine(root, "eng/Versions.props"));
string releaseVersion = Property(versions, "RendezvousVersion");
string workflow = File.ReadAllText(Path.Combine(root, ".gitea/workflows/release.yml"));
Assert.Contains("tags:", workflow, StringComparison.Ordinal);
Assert.Contains("RELEASE_TOKEN", workflow, StringComparison.Ordinal);
Assert.Contains("RELEASE_USERNAME", workflow, StringComparison.Ordinal);
Assert.Contains("COSIGN_PRIVATE_KEY", workflow, StringComparison.Ordinal);
Assert.DoesNotContain(":latest", workflow, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("skip-duplicate", workflow, StringComparison.OrdinalIgnoreCase);
Assert.Contains("check-compatibility.sh", workflow, StringComparison.Ordinal);
Assert.Contains("--platform linux/amd64", workflow, StringComparison.Ordinal);
Assert.DoesNotContain("aquasecurity/trivy-action", workflow, StringComparison.Ordinal);
Assert.Contains(
"https://github.com/aquasecurity/trivy/releases/download/v0.69.3/trivy_0.69.3_Linux-64bit.tar.gz",
workflow,
StringComparison.Ordinal);
Assert.Contains(
"1816b632dfe529869c740c0913e36bd1629cb7688bd5634f4a858c1d57c88b75",
workflow,
StringComparison.Ordinal);
Assert.Contains("sha256sum --check --strict", workflow, StringComparison.Ordinal);
Assert.Contains("TRIVY_CACHE_DIR=$RENDEZVOUS_WORK_DIR/trivy-cache", workflow, StringComparison.Ordinal);
// Gitea answers the v3-era artifacts API only, so upload-artifact stays on
// v3 and never gates the release it is only meant to help debug.
Assert.DoesNotContain(
"actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02",
workflow,
StringComparison.Ordinal);
Assert.Contains(
"actions/upload-artifact@ff15f0306b3f739f7b6fd43fb5d26cd321bd4de5 # v3.2.1",
workflow,
StringComparison.Ordinal);
Assert.Contains("continue-on-error: true", workflow, StringComparison.Ordinal);
// The signing client is fetched the way the scanner is: a pinned asset
// checked against a pinned digest, with no action resolved off github.com.
Assert.DoesNotContain("sigstore/cosign-installer", workflow, StringComparison.Ordinal);
Assert.Contains(
"https://github.com/sigstore/cosign/releases/download/v3.0.6/cosign-linux-amd64",
workflow,
StringComparison.Ordinal);
Assert.Contains(
"c956e5dfcac53d52bcf058360d579472f0c1d2d9b69f55209e256fe7783f4c74",
workflow,
StringComparison.Ordinal);
Assert.Contains("echo \"$cosign_bin_dir\" >>\"$GITHUB_PATH\"", workflow, StringComparison.Ordinal);
Assert.Contains("--exit-code 1", workflow, StringComparison.Ordinal);
Assert.Contains("--severity HIGH,CRITICAL", workflow, StringComparison.Ordinal);
Assert.DoesNotContain("--ignore-unfixed", workflow, StringComparison.Ordinal);
Assert.Contains("--format spdx-json", workflow, StringComparison.Ordinal);
Assert.Contains(
"--output \"$RENDEZVOUS_RELEASE_DIR/FinalFactory.Rendezvous.Container.$RENDEZVOUS_VERSION.spdx.json\"",
workflow,
StringComparison.Ordinal);
Assert.Contains("normalize-container-sbom", workflow, StringComparison.Ordinal);
Assert.Contains("finalize-release-candidate.sh", workflow, StringComparison.Ordinal);
Assert.Contains("verify-real-consumers.sh", workflow, StringComparison.Ordinal);
Assert.Contains("RENDEZVOUS_RELEASE_BUILDER", workflow, StringComparison.Ordinal);
Assert.Contains("--image-id", workflow, StringComparison.Ordinal);
string publisher = File.ReadAllText(Path.Combine(root, "scripts/publish-release.sh"));
Assert.Contains("expected_image_id", publisher, StringComparison.Ordinal);
Assert.Contains("finalize-signing-ready-release.sh", publisher, StringComparison.Ordinal);
XDocument packages = XDocument.Load(Path.Combine(root, "Directory.Packages.props"));
XElement liteNetLib = Assert.Single(packages.Descendants("PackageVersion"), static item => (string?)item.Attribute("Include") == "LiteNetLib");
Assert.Equal("[$(LiteNetLibVersion)]", (string?)liteNetLib.Attribute("Version"));
string changelog = File.ReadAllText(Path.Combine(root, "CHANGELOG.md"));
Assert.Contains("### Compatibility", changelog, StringComparison.Ordinal);
Assert.Contains("### Security and configuration", changelog, StringComparison.Ordinal);
Assert.Contains("### Migration", changelog, StringComparison.Ordinal);
Assert.DoesNotContain($"{releaseVersion} - Unreleased", changelog, StringComparison.Ordinal);
foreach (string consumer in new[] { "spacegame", "unscouted" })
{
string consumerDirectory = Path.Combine(root, "tests/consumers", consumer);
string projectPath = Assert.Single(Directory.GetFiles(consumerDirectory, "*.csproj"));
XDocument consumerProject = XDocument.Load(projectPath);
XElement[] references = consumerProject.Descendants("PackageReference")
.Where(static item => ((string?)item.Attribute("Include"))?.StartsWith("FinalFactory.Rendezvous.", StringComparison.Ordinal) == true)
.ToArray();
Assert.Equal(2, references.Length);
Assert.All(references, static reference =>
Assert.Equal("[$(RendezvousPackageVersion)]", (string?)reference.Attribute("Version")));
Assert.Equal(
"false",
consumerProject.Descendants("RestorePackagesWithLockFile").Single().Value);
}
XDocument unscouted = XDocument.Load(Path.Combine(
root,
"tests/consumers/unscouted/Unscouted.Rendezvous.Consumer.csproj"));
XElement directTransport = Assert.Single(
unscouted.Descendants("PackageReference"),
static item => (string?)item.Attribute("Include") == "LiteNetLib");
Assert.Equal("[2.1.4]", (string?)directTransport.Attribute("Version"));
using JsonDocument consumers = JsonDocument.Parse(
File.ReadAllText(Path.Combine(root, "eng/consumer-revisions.json")));
JsonElement[] pinnedConsumers = consumers.RootElement.GetProperty("consumers")
.EnumerateArray()
.ToArray();
Assert.Equal(
["SpaceGame", "Unscouted"],
pinnedConsumers.Select(static item => item.GetProperty("name").GetString()!).ToArray());
Assert.All(pinnedConsumers, static item =>
Assert.Matches("^[0-9a-f]{40}$", item.GetProperty("revision").GetString()));
string realConsumerGate = File.ReadAllText(Path.Combine(root, "scripts", "verify-real-consumers.sh"));
Assert.Contains("<PackageReference Remove=\"FinalFactory.Rendezvous.Client\" />", realConsumerGate, StringComparison.Ordinal);
Assert.Contains("<PackageReference Remove=\"FinalFactory.Rendezvous.Contracts\" />", realConsumerGate, StringComparison.Ordinal);
}
[Fact]
public void AutomationProvidesNet8RuntimeForProcessVerification()
{
string root = FindRepositoryRoot();
string qualityWorkflow = File.ReadAllText(Path.Combine(root, ".gitea/workflows/ci.yml"));
Assert.Equal(2, qualityWorkflow.Split("8.0.128", StringSplitOptions.None).Length - 1);
string releaseWorkflow = File.ReadAllText(Path.Combine(root, ".gitea/workflows/release.yml"));
Assert.Contains("8.0.128", releaseWorkflow, StringComparison.Ordinal);
string releaseBuilder = File.ReadAllText(Path.Combine(root, "eng/release-builder.Dockerfile"));
Assert.Contains("runtime:8.0.28-noble@sha256:", releaseBuilder, StringComparison.Ordinal);
Assert.Contains("Microsoft.NETCore.App/8.0.28", releaseBuilder, StringComparison.Ordinal);
}
[Fact]
public void AutomationUsesPerRunContainerPorts()
{
string root = FindRepositoryRoot();
string compose = File.ReadAllText(Path.Combine(root, "deploy/compose/compose.yaml"));
Assert.Contains("${RENDEZVOUS_HTTP_HOST_PORT:-8080}:8080/tcp", compose, StringComparison.Ordinal);
Assert.Contains("${RENDEZVOUS_UDP_HOST_PORT:-9050}:9050/udp", compose, StringComparison.Ordinal);
Assert.Contains("${RENDEZVOUS_CONFIG_SOURCE:-./appsettings.Production.json}", compose, StringComparison.Ordinal);
Assert.Contains("${RENDEZVOUS_SECRET_SOURCE:-./secrets/signing-key}", compose, StringComparison.Ordinal);
foreach (string workflowName in new[] { "ci.yml", "release.yml" })
{
string workflow = File.ReadAllText(Path.Combine(root, ".gitea/workflows", workflowName));
Assert.Contains("GITHUB_RUN_ID", workflow, StringComparison.Ordinal);
Assert.Contains("--network \"container:${container_id}\"", workflow, StringComparison.Ordinal);
Assert.Contains("publisher_credential", workflow, StringComparison.Ordinal);
Assert.Contains("RENDEZVOUS_PUBLISHER_CREDENTIAL", workflow, StringComparison.Ordinal);
Assert.Contains("for attempt in {1..180}", workflow, StringComparison.Ordinal);
Assert.Contains("sleep 1", workflow, StringComparison.Ordinal);
Assert.Contains("http://127.0.0.1:8080/", workflow, StringComparison.Ordinal);
Assert.Contains("127.0.0.1:9050", workflow, StringComparison.Ordinal);
Assert.Contains("RENDEZVOUS_HTTP_HOST_PORT", workflow, StringComparison.Ordinal);
Assert.Contains("RENDEZVOUS_UDP_HOST_PORT", workflow, StringComparison.Ordinal);
Assert.Contains("RENDEZVOUS_SMOKE_HTTP_URL", workflow, StringComparison.Ordinal);
Assert.Contains("RENDEZVOUS_SMOKE_UDP_ENDPOINT", workflow, StringComparison.Ordinal);
Assert.Contains("runner_workspace_source", workflow, StringComparison.Ordinal);
Assert.Contains("RENDEZVOUS_CONFIG_SOURCE", workflow, StringComparison.Ordinal);
Assert.Contains("RENDEZVOUS_SECRET_SOURCE", workflow, StringComparison.Ordinal);
}
}
[Fact]
public void ContainerGateUsesValidInspectTemplates()
{
string root = FindRepositoryRoot();
string workflow = File.ReadAllText(Path.Combine(root, ".gitea/workflows/ci.yml"));
Assert.Contains(
"eq .Destination \"/app/appsettings.Production.json\"",
workflow,
StringComparison.Ordinal);
Assert.Contains(
"eq .Destination \"/run/secrets/rendezvous-signing-key\"",
workflow,
StringComparison.Ordinal);
Assert.DoesNotContain("eq .Destination \\\"", workflow, StringComparison.Ordinal);
}
[Fact]
public void ServerSourceManifestIsCompleteSortedAndDeterministic()
{
string root = FindRepositoryRoot();
string projectDirectory = Path.Combine(root, "src/FinalFactory.Rendezvous.Server");
XDocument project = XDocument.Load(Path.Combine(projectDirectory, "FinalFactory.Rendezvous.Server.csproj"));
string[] declared = project.Descendants("Compile")
.Select(static item => (string)item.Attribute("Include")!)
.ToArray();
string[] actual = Directory.GetFiles(projectDirectory, "*.cs", SearchOption.AllDirectories)
.Where(static path => !path.Contains($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}", StringComparison.Ordinal)
&& !path.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}", StringComparison.Ordinal))
.Select(path => Path.GetRelativePath(projectDirectory, path).Replace(Path.DirectorySeparatorChar, '/'))
.Order(StringComparer.Ordinal)
.ToArray();
Assert.Equal(actual, declared);
}
[Fact]
public void ProductionReadinessRecordIsFailClosedAndCanaryEvidenceIsRedacted()
{
string root = FindRepositoryRoot();
using JsonDocument readiness = JsonDocument.Parse(File.ReadAllText(Path.Combine(
root,
"docs/evidence/production-readiness-v1.json")));
JsonElement document = readiness.RootElement;
Assert.Equal(1, document.GetProperty("schemaVersion").GetInt32());
Assert.Equal("rendezvous-production-readiness", document.GetProperty("kind").GetString());
Assert.Matches("^[0-9a-f]{40}$", document.GetProperty("evaluatedCommit").GetString());
JsonElement[] local = document.GetProperty("localGates").EnumerateArray().ToArray();
JsonElement[] external = document.GetProperty("externalGates").EnumerateArray().ToArray();
Assert.Equal(6, local.Length);
Assert.Equal(13, external.Length);
JsonElement[] gates = local.Concat(external).ToArray();
Assert.Equal(gates.Length, gates.Select(static gate => gate.GetProperty("id").GetString()).Distinct().Count());
Assert.All(gates, static gate =>
{
Assert.True(gate.GetProperty("status").GetString() is "pass" or "pending" or "fail");
string evidence = Assert.IsType<string>(gate.GetProperty("evidenceRef").GetString());
Assert.False(Path.IsPathRooted(evidence));
Assert.DoesNotContain("..", evidence, StringComparison.Ordinal);
Assert.True(gate.GetProperty("note").GetString()!.Length <= 240);
});
bool allPass = gates.All(static gate => gate.GetProperty("status").GetString() == "pass");
Assert.Equal(allPass ? "ready" : "not-ready", document.GetProperty("decision").GetString());
string canary = File.ReadAllText(Path.Combine(root, "scripts/run-real-network-canary.sh"));
Assert.Contains("umask 077", canary, StringComparison.Ordinal);
Assert.Contains("client-expected-failure", canary, StringComparison.Ordinal);
Assert.Contains("exit_code\" -eq 12", canary, StringComparison.Ordinal);
Assert.Contains(".addressFamily == $family", canary, StringComparison.Ordinal);
Assert.Contains("identifiers:\"not-in-summary\"", canary, StringComparison.Ordinal);
Assert.DoesNotContain("jq -c . \"$raw_log\"", canary, StringComparison.Ordinal);
string checker = File.ReadAllText(Path.Combine(root, "eng/check_production_readiness.py"));
Assert.Contains("return 3", checker, StringComparison.Ordinal);
Assert.Contains("FORBIDDEN_KEY_PARTS", checker, StringComparison.Ordinal);
Assert.Contains("decision must be", checker, StringComparison.Ordinal);
Assert.Contains("candidate capacity evidence", checker, StringComparison.Ordinal);
Assert.Contains("rendezvous-external-gate-attestation", checker, StringComparison.Ordinal);
Assert.Contains("does not resolve to a repository evidence file", checker, StringComparison.Ordinal);
}
private static string Property(XDocument document, string name) =>
document.Descendants(name).Single().Value;
private static string FindRepositoryRoot()
{
DirectoryInfo? directory = new(AppContext.BaseDirectory);
while (directory is not null)
{
if (File.Exists(Path.Combine(directory.FullName, "Rendezvous.slnx")))
{
return directory.FullName;
}
directory = directory.Parent;
}
throw new DirectoryNotFoundException("Could not locate repository root.");
}
}