diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml
index 316ef74..86c6ad3 100644
--- a/.gitea/workflows/ci.yml
+++ b/.gitea/workflows/ci.yml
@@ -14,16 +14,34 @@ jobs:
timeout-minutes: 15
steps:
- name: Check out repository
- uses: actions/checkout@v4
+ uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
+ with:
+ fetch-depth: 0
- name: Install .NET SDK
- uses: actions/setup-dotnet@v4
+ uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
with:
dotnet-version: 10.0.301
- name: Restore locked dependencies
run: dotnet restore Rendezvous.slnx --locked-mode
+ - name: Verify dependency licenses and reviewed transport pin
+ run: python3 eng/release_artifacts.py policy --root .
+
+ - name: Reject vulnerable direct or transitive packages
+ shell: bash
+ run: |
+ set -euo pipefail
+ dotnet package list --project Rendezvous.slnx \
+ --vulnerable --include-transitive --no-restore --format json \
+ >"${RUNNER_TEMP}/nuget-vulnerabilities.json"
+ python3 eng/release_artifacts.py audit \
+ --input "${RUNNER_TEMP}/nuget-vulnerabilities.json"
+
+ - name: Enforce compatibility version bumps
+ run: ./scripts/check-compatibility.sh origin/main
+
- name: Verify formatting and analyzers
run: dotnet format Rendezvous.slnx --verify-no-changes --no-restore
@@ -98,10 +116,10 @@ jobs:
timeout-minutes: 15
steps:
- name: Check out repository
- uses: actions/checkout@v4
+ uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Install .NET SDK
- uses: actions/setup-dotnet@v4
+ uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
with:
dotnet-version: 10.0.301
@@ -127,7 +145,10 @@ jobs:
chmod 0444 "$secret"
export RENDEZVOUS_UID=1654
export RENDEZVOUS_GID=1654
- docker compose -f "$compose_file" up --build --detach
+ export SOURCE_REVISION_ID="$GITHUB_SHA"
+ docker compose -f "$compose_file" build \
+ --build-arg SOURCE_REVISION_ID="$SOURCE_REVISION_ID"
+ docker compose -f "$compose_file" up --no-build --detach
container_id="$(docker compose -f "$compose_file" ps -q rendezvous)"
test -n "$container_id"
test "$(docker inspect --format '{{.Config.User}}' "$container_id")" = "1654:1654"
diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml
new file mode 100644
index 0000000..5b86cbd
--- /dev/null
+++ b/.gitea/workflows/release.yml
@@ -0,0 +1,193 @@
+name: immutable-release
+
+on:
+ push:
+ tags:
+ - "v*.*.*"
+
+concurrency:
+ group: release-${{ gitea.ref_name }}
+ cancel-in-progress: false
+
+jobs:
+ release:
+ runs-on: ubuntu-latest
+ timeout-minutes: 45
+ environment: production
+ steps:
+ - name: Check out immutable tag
+ uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
+ with:
+ fetch-depth: 0
+
+ - name: Install pinned .NET SDK
+ uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
+ with:
+ dotnet-version: 10.0.301
+
+ - name: Install pinned Buildx and BuildKit
+ uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
+ with:
+ version: v0.35.0
+ install: true
+ driver-opts: image=moby/buildkit:v0.25.2@sha256:0f63d66f8d2de0bd16438284831a3e9ee6ca7cd57b6eb3ed6e38a7a456590fa7
+
+ - name: Validate tag and produce reproducible artifacts
+ shell: bash
+ run: |
+ set -euo pipefail
+ version="${GITHUB_REF_NAME#v}"
+ ./scripts/check-release-tag.sh "$GITHUB_REF_NAME"
+ previous_tag="$(git tag --merged HEAD^ --list 'v*.*.*' --sort=-version:refname | sed -n '1p')"
+ if [[ -n "$previous_tag" ]]; then
+ ./scripts/check-compatibility.sh "$previous_tag"
+ elif [[ -n "$(git tag --list 'v*.*.*' | sed -n '1p')" ]]; then
+ echo "No prior release tag is an ancestor of $GITHUB_REF_NAME." >&2
+ exit 1
+ else
+ ./scripts/check-compatibility.sh __initial_release_without_base__
+ fi
+ release_builder="rendezvous-release-builder:${GITHUB_SHA}"
+ docker buildx build \
+ --platform linux/amd64 \
+ --file eng/release-builder.Dockerfile \
+ --target release-builder \
+ --load \
+ --tag "$release_builder" .
+ mkdir -p "${RUNNER_TEMP}/release-home" "${RUNNER_TEMP}/nuget"
+ docker run --rm \
+ --user "$(id -u):$(id -g)" \
+ --env HOME="${RUNNER_TEMP}/release-home" \
+ --env NUGET_PACKAGES="${RUNNER_TEMP}/nuget" \
+ --volume "$GITHUB_WORKSPACE:/source" \
+ --volume "${RUNNER_TEMP}:${RUNNER_TEMP}" \
+ --workdir /source \
+ "$release_builder" \
+ ./scripts/build-release.sh "$version" "${RUNNER_TEMP}/release/$version"
+ ./scripts/verify-real-consumers.sh "$version" "${RUNNER_TEMP}/release/$version"
+ echo "RENDEZVOUS_VERSION=$version" >>"$GITHUB_ENV"
+ echo "RENDEZVOUS_RELEASE_DIR=${RUNNER_TEMP}/release/$version" >>"$GITHUB_ENV"
+ echo "RENDEZVOUS_RELEASE_BUILDER=$release_builder" >>"$GITHUB_ENV"
+
+ - name: Build exact container candidate
+ shell: bash
+ run: |
+ set -euo pipefail
+ export SOURCE_DATE_EPOCH="$(git show -s --format=%ct HEAD)"
+ common=(
+ --no-cache
+ --pull=false
+ --provenance=false
+ --platform linux/amd64
+ --build-arg SOURCE_DATE_EPOCH="$SOURCE_DATE_EPOCH"
+ --build-arg SOURCE_REVISION_ID="$GITHUB_SHA"
+ )
+ release_tag="git.finalfactory.de/heikyu/rendezvous:${RENDEZVOUS_VERSION}"
+ image_one="${RUNNER_TEMP}/rendezvous-image-1.tar"
+ image_two="${RUNNER_TEMP}/rendezvous-image-2.tar"
+ docker buildx build "${common[@]}" --tag "$release_tag" \
+ --output "type=docker,dest=$image_one,rewrite-timestamp=true" .
+ docker buildx build "${common[@]}" --tag "$release_tag" \
+ --output "type=docker,dest=$image_two,rewrite-timestamp=true" .
+ cmp --silent "$image_one" "$image_two"
+ docker load --input "$image_one"
+ candidate_id="$(docker image inspect --format '{{.Id}}' "$release_tag")"
+ buildkit_version="$(docker buildx inspect --bootstrap | sed -n 's/.*BuildKit version: *//p' | sed -n '1p')"
+ docker run --rm \
+ --user "$(id -u):$(id -g)" \
+ --volume "$GITHUB_WORKSPACE:/source" \
+ --volume "${RUNNER_TEMP}:${RUNNER_TEMP}" \
+ --workdir /source \
+ "$RENDEZVOUS_RELEASE_BUILDER" \
+ python3 eng/release_artifacts.py record-container-build \
+ --provenance "$RENDEZVOUS_RELEASE_DIR/release-provenance.json" \
+ --buildx-version "$(docker buildx version)" \
+ --buildkit-version "$buildkit_version" \
+ --image-id "$candidate_id"
+
+ - name: Stage HTTP registration, browse, and authenticated UDP traversal
+ shell: bash
+ run: |
+ set -euo pipefail
+ secret="deploy/compose/secrets/signing-key"
+ cleanup() {
+ RENDEZVOUS_UID=1654 RENDEZVOUS_GID=1654 RENDEZVOUS_IMAGE="git.finalfactory.de/heikyu/rendezvous:${RENDEZVOUS_VERSION}" \
+ docker compose -f deploy/compose/compose.yaml down --volumes >/dev/null 2>&1 || true
+ rm -f "$secret"
+ }
+ trap cleanup EXIT
+ install -d -m 0700 deploy/compose/secrets
+ openssl rand -out "$secret" 32
+ chmod 0444 "$secret"
+ export RENDEZVOUS_UID=1654
+ export RENDEZVOUS_GID=1654
+ export RENDEZVOUS_IMAGE="git.finalfactory.de/heikyu/rendezvous:${RENDEZVOUS_VERSION}"
+ docker compose -f deploy/compose/compose.yaml up --detach --no-build
+ for attempt in {1..100}; do
+ curl --fail --silent http://127.0.0.1:8080/health/ready >/dev/null 2>&1 && break
+ if (( attempt == 100 )); then
+ docker compose -f deploy/compose/compose.yaml logs rendezvous
+ exit 1
+ fi
+ sleep 0.1
+ done
+ ./scripts/smoke-deployment.sh
+
+ - name: Scan candidate for high and critical vulnerabilities
+ uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # v0.35.0, post-incident safe SHA
+ with:
+ image-ref: git.finalfactory.de/heikyu/rendezvous:${{ env.RENDEZVOUS_VERSION }}
+ version: v0.69.3
+ format: table
+ exit-code: "1"
+ ignore-unfixed: false
+ severity: HIGH,CRITICAL
+
+ - name: Generate container SPDX inventory
+ uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # v0.35.0, post-incident safe SHA
+ with:
+ image-ref: git.finalfactory.de/heikyu/rendezvous:${{ env.RENDEZVOUS_VERSION }}
+ version: v0.69.3
+ format: spdx-json
+ output: ${{ env.RENDEZVOUS_RELEASE_DIR }}/FinalFactory.Rendezvous.Container.${{ env.RENDEZVOUS_VERSION }}.spdx.json
+
+ - name: Finalize checksums over the publish-ready candidate
+ shell: bash
+ run: |
+ set -euo pipefail
+ source_date_epoch="$(git show -s --format=%ct HEAD)"
+ docker run --rm \
+ --user "$(id -u):$(id -g)" \
+ --volume "$GITHUB_WORKSPACE:/source" \
+ --volume "${RUNNER_TEMP}:${RUNNER_TEMP}" \
+ --workdir /source \
+ "$RENDEZVOUS_RELEASE_BUILDER" \
+ bash -c 'python3 eng/release_artifacts.py normalize-container-sbom \
+ --file "$1/FinalFactory.Rendezvous.Container.$2.spdx.json" \
+ --version "$2" \
+ --commit "$3" \
+ --source-date-epoch "$4" \
+ && ./scripts/finalize-release-candidate.sh "$2" "$1"' \
+ _ "$RENDEZVOUS_RELEASE_DIR" "$RENDEZVOUS_VERSION" "$GITHUB_SHA" "$source_date_epoch"
+
+ - name: Preserve verified candidate artifacts
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
+ with:
+ name: rendezvous-${{ env.RENDEZVOUS_VERSION }}
+ path: ${{ env.RENDEZVOUS_RELEASE_DIR }}
+ if-no-files-found: error
+ retention-days: 30
+
+ - name: Install pinned signing client
+ uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
+ with:
+ cosign-release: v3.0.6
+
+ - name: Publish once, sign, attest, and create release
+ shell: bash
+ env:
+ RENDEZVOUS_RELEASE_USERNAME: ${{ secrets.RELEASE_USERNAME }}
+ RENDEZVOUS_RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
+ COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
+ COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
+ run: ./scripts/publish-release.sh "$RENDEZVOUS_VERSION" "$RENDEZVOUS_RELEASE_DIR"
diff --git a/.gitignore b/.gitignore
index cf35970..7bc481d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,5 +8,7 @@ TestResults/
*.userosscache
deploy/compose/.smoke.env
artifacts/
+__pycache__/
+*.pyc
deploy/compose/secrets/*
!deploy/compose/secrets/.gitignore
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..d9b3f14
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,25 @@
+# Changelog
+
+All notable Rendezvous release changes are recorded here. Versions follow
+Semantic Versioning; HTTP, UDP, and connection-ticket format compatibility is
+tracked separately and called out for every release.
+
+## 1.0.0 - 2026-07-16
+
+### Compatibility
+
+- Initial Client and Contracts package major: 1.
+- HTTP contract: v1; UDP mediation contract: v1; connection-ticket format: v1.
+- Server accepts Client 1.0.0 through the latest compatible 1.x release.
+- Client traversal is pinned to LiteNetLib 2.1.4; LiteNetLib 1.x is unsupported.
+
+### Security and configuration
+
+- Packages contain no reusable credentials or environment configuration.
+- Production server startup requires provisioned signing keys and the hardened
+ single-active deployment configuration.
+
+### Migration
+
+- This is the first packaged release; no prior package or wire migration exists.
+- Consumers must pin both Rendezvous packages to the same exact version.
diff --git a/Directory.Build.props b/Directory.Build.props
index e1a705e..9862903 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -1,4 +1,5 @@
+
latest-recommended
true
@@ -8,8 +9,35 @@
enable
latest
enable
+ $(RendezvousVersion)
+ $(RendezvousVersion)
+ $(RendezvousMajorVersion).0.0.0
+ $(RendezvousMajorVersion).$(RendezvousMinorVersion).$(RendezvousPatchVersion).0
+ Final Factory
+ Final Factory
+ https://git.finalfactory.de/HeiKyu/Rendezvous
+ git
+ https://git.finalfactory.de/HeiKyu/Rendezvous
+ true
+ true
+ true
+ true
+ snupkg
+ See CHANGELOG.md in the package and repository.
true
true
+ true
+ all
+ moderate
true
+
+
+
+
+
+
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 209a809..c220b0a 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -4,7 +4,7 @@
true
-
+
diff --git a/Dockerfile b/Dockerfile
index 6a3844a..b8a6071 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,8 +1,10 @@
# syntax=docker/dockerfile:1.7@sha256:a57df69d0ea827fb7266491f2813635de6f17269be881f696fbfdf2d83dda33e
FROM mcr.microsoft.com/dotnet/sdk:10.0.301-noble@sha256:ea8bde36c11b6e7eec2656d0e59101d4462f6bd630730f2c8201ed0572b295d5 AS build
+ARG SOURCE_REVISION_ID
WORKDIR /source
COPY Directory.Build.props Directory.Packages.props NuGet.config global.json Rendezvous.slnx ./
+COPY eng/Versions.props eng/Versions.props
COPY src/FinalFactory.Rendezvous.Contracts/FinalFactory.Rendezvous.Contracts.csproj src/FinalFactory.Rendezvous.Contracts/packages.lock.json src/FinalFactory.Rendezvous.Contracts/
COPY src/FinalFactory.Rendezvous.Server/FinalFactory.Rendezvous.Server.csproj src/FinalFactory.Rendezvous.Server/packages.lock.json src/FinalFactory.Rendezvous.Server/
RUN dotnet restore src/FinalFactory.Rendezvous.Server/FinalFactory.Rendezvous.Server.csproj --locked-mode
@@ -14,7 +16,10 @@ RUN dotnet publish src/FinalFactory.Rendezvous.Server/FinalFactory.Rendezvous.Se
--no-restore \
--output /out \
/p:UseAppHost=false \
- /p:OpenApiGenerateDocuments=false
+ /p:OpenApiGenerateDocuments=false \
+ /p:ContinuousIntegrationBuild=true \
+ /p:RepositoryCommit="$SOURCE_REVISION_ID" \
+ /p:SourceRevisionId="$SOURCE_REVISION_ID"
FROM mcr.microsoft.com/dotnet/aspnet:10.0.9-noble-chiseled@sha256:f820c4fbfb8bb204c3bbe05c69d48cd039cd0e67aa8f13ac1cec168819b90643 AS runtime
diff --git a/README.md b/README.md
index a0363f4..b65d428 100644
--- a/README.md
+++ b/README.md
@@ -84,8 +84,8 @@ directory leases, authenticated join attempts, LiteNetLib mediator, caller-owned
SDK coordination, typed connection outcomes, thin public-SDK diagnostic client,
deterministic NAT topology harness, hostile-input controls,
observability/operator surface, secure single-active Linux deployment, and
-numeric capacity/resilience gates are implemented. Packaging, consumer pilots,
-and final production-readiness gates remain in progress;
+numeric capacity/resilience gates, and reproducible signed release pipeline are
+implemented. Consumer pilots and final production-readiness gates remain in progress;
participating games must not treat the current repository as a finished production
service until those gates land.
@@ -106,6 +106,9 @@ hardening, smoke procedure, and recovery lifecycle are documented in
The numeric core-state candidate profile, public launch objectives, accelerated
soak, resilience matrix, and single-active scaling decision are recorded in
[capacity and resilience gates](docs/operations/capacity-and-resilience.md).
+Release versions, compatibility windows, immutable artifact construction,
+signing, staged promotion, rollback, and migration are defined in
+[releases and compatibility](docs/releases/README.md).
The scriptable host/browser/join diagnostic and its stable automation contract are
documented in the [TestClient integration guide](docs/integration/test-client.md).
The always-on three-party scenarios, optional Linux namespace topology, and
diff --git a/deploy/compose/compose.yaml b/deploy/compose/compose.yaml
index 2d7dc97..be81af3 100644
--- a/deploy/compose/compose.yaml
+++ b/deploy/compose/compose.yaml
@@ -2,7 +2,7 @@ name: rendezvous-local
services:
rendezvous:
- image: finalfactory/rendezvous:local
+ image: "${RENDEZVOUS_IMAGE:-finalfactory/rendezvous:local}"
build:
context: ../..
dockerfile: Dockerfile
diff --git a/docs/api/rendezvous-v1.json b/docs/api/rendezvous-v1.json
index 81bf889..63d8c8b 100644
--- a/docs/api/rendezvous-v1.json
+++ b/docs/api/rendezvous-v1.json
@@ -2931,6 +2931,59 @@
}
}
},
+ "OperatorCompatibilityResponse": {
+ "required": [
+ "serverVersion",
+ "minimumClientVersion",
+ "maximumClientMajorVersion",
+ "httpContractVersions",
+ "udpContractVersions",
+ "connectionTicketFormatVersions",
+ "liteNetLibMajorVersion",
+ "gameplayProtocolCompatibility"
+ ],
+ "type": "object",
+ "properties": {
+ "serverVersion": {
+ "type": "string"
+ },
+ "minimumClientVersion": {
+ "type": "string"
+ },
+ "maximumClientMajorVersion": {
+ "type": "integer",
+ "format": "int32"
+ },
+ "httpContractVersions": {
+ "type": "array",
+ "items": {
+ "type": "integer",
+ "format": "int32"
+ }
+ },
+ "udpContractVersions": {
+ "type": "array",
+ "items": {
+ "type": "integer",
+ "format": "int32"
+ }
+ },
+ "connectionTicketFormatVersions": {
+ "type": "array",
+ "items": {
+ "type": "integer",
+ "format": "int32"
+ }
+ },
+ "liteNetLibMajorVersion": {
+ "type": "integer",
+ "format": "int32"
+ },
+ "gameplayProtocolCompatibility": {
+ "type": "string"
+ }
+ }
+ },
"OperatorReadinessResponse": {
"required": [
"httpListener",
@@ -3009,6 +3062,7 @@
"OperatorStatusResponse": {
"required": [
"status",
+ "compatibility",
"readiness",
"store",
"tenants",
@@ -3020,6 +3074,9 @@
"status": {
"type": "string"
},
+ "compatibility": {
+ "$ref": "#/components/schemas/OperatorCompatibilityResponse"
+ },
"readiness": {
"$ref": "#/components/schemas/OperatorReadinessResponse"
},
diff --git a/docs/contracts/README.md b/docs/contracts/README.md
index 294c386..1a23995 100644
--- a/docs/contracts/README.md
+++ b/docs/contracts/README.md
@@ -14,5 +14,7 @@ The public .NET types live in `FinalFactory.Rendezvous.Contracts`, target
vectors and a public-API snapshot make accidental wire or source compatibility
changes fail the normal test gate.
-Any incompatible change requires a new contract version. Additive JSON fields
-may be introduced within v1 because v1 readers ignore unknown object members.
+Readers ignore unknown JSON members, but the release gate deliberately treats
+any accepted OpenAPI or golden JSON surface drift as a contract-version change.
+That conservative policy makes additive and incompatible published changes
+equally visible to consumers instead of relying on an undocumented minor shape.
diff --git a/docs/releases/README.md b/docs/releases/README.md
new file mode 100644
index 0000000..716e48d
--- /dev/null
+++ b/docs/releases/README.md
@@ -0,0 +1,150 @@
+# Releases and compatibility
+
+Tracking: #19
+
+Rendezvous releases are immutable, reproducible, and promoted only after the
+same candidate has passed package, consumer, server, container, and staging
+checks. A release consists of matching Client and Contracts NuGet packages, a
+framework-dependent Linux server archive, a versioned linux/amd64 OCI image,
+package/runtime and container SPDX inventories, checksums, provenance, release
+notes, and signatures. No workflow publishes a `latest` tag.
+
+## Version dimensions
+
+The central values in `eng/Versions.props` are the authority. Client,
+Contracts, and Server use SemVer. HTTP, UDP mediation, and connection-ticket
+formats advance independently so a wire change cannot hide inside a package
+patch release. The current machine-readable matrix is
+[`compatibility.json`](compatibility.json); the authenticated operator status
+endpoint exposes the server's supported window at runtime.
+
+| Surface | Current | Compatibility rule |
+| --- | ---: | --- |
+| Client and Contracts | 1.0.0 | Matching exact versions; source/API breaks require a package major bump. |
+| Server | 1.0.0 | Accepts Client 1.0.0 through compatible 1.x releases. |
+| HTTP contract | 1 | Frozen OpenAPI, JSON vectors, and public API snapshot. |
+| UDP mediation | 1 | Frozen codec vectors; incompatible bytes require UDP v2. |
+| Connection ticket | 1 | A format change requires a new accepted ticket version and migration window. |
+| LiteNetLib | 2.1.4 | Exact dependency; LiteNetLib 1.x is rejected by package and consumer gates. |
+| Gameplay protocol | Per tenant | Exact match; Rendezvous does not translate gameplay protocols. |
+
+`scripts/check-compatibility.sh` compares protected snapshots against the base
+revision. A changed public API snapshot requires a package major increase; an
+HTTP or UDP golden surface requires the corresponding contract increase. The
+normal tests also compare implementation output with the current versioned
+snapshots. For a deliberate break, add a new versioned contract directory and
+documentation instead of replacing the prior version's evidence.
+
+## Candidate build
+
+From a clean tagged checkout:
+
+```bash
+./scripts/check-release-tag.sh v1.0.0
+./scripts/build-release.sh 1.0.0
+./scripts/verify-release.sh 1.0.0
+```
+
+The tag build runs inside the digest-pinned `release-builder` Docker stage,
+which combines the pinned SDK with a pinned Python runtime. It uses the locked
+dependency graph, enforces NuGet
+advisories and approved licenses, runs formatting/build/tests, regenerates the
+OpenAPI drift check, and packs twice after a clean rebuild. NuGet's random OPC
+relationship identifiers are canonicalized before comparison; both `.nupkg`
+and `.snupkg` outputs must then be byte-identical. Package metadata identifies the exact
+repository commit, portable PDBs carry SourceLink data, and the Linux archive,
+runtime SBOM timestamp, and checksum ordering are deterministic. Buildx and
+BuildKit are also pinned for the linux/amd64 OCI build. Provenance records each
+artifact-producing tool version; an out-of-band rebuild must use the pinned
+builder and recorded versions rather than treating the runner label as a
+reproducibility guarantee.
+All project-authored artifact normalization and checksum updates run inside the
+same pinned builder; host tools only orchestrate or verify. The separately
+pinned Trivy and Cosign tools produce the container inventory and signatures.
+The tag workflow also performs two no-cache image builds with the commit time
+and revision fixed, disables unsigned builder-generated attestations, and
+requires identical OCI image IDs before signing the project provenance.
+
+The local gate builds net8.0 SpaceGame- and Unscouted-shaped API fixtures using
+only the candidate feed plus NuGet.org; the Unscouted fixture also carries its
+real direct LiteNetLib 2.1.4 pin. The tag gate separately checks out the exact
+SpaceGame and Unscouted revisions in `eng/consumer-revisions.json`, injects
+exact candidate references without modifying those repositories, and restores
+their real game/network projects. Both paths must resolve the matching Client
+and Contracts version and LiteNetLib 2.1.4. Updating a consumer revision is a
+reviewed compatibility change, not a floating-main check.
+
+## Promotion and publication
+
+Pushing the matching `vMAJOR.MINOR.PATCH` tag starts the tag-only release
+workflow. Before any external write it:
+
+1. builds and verifies the artifact set;
+2. builds the exact versioned container candidate;
+3. starts that image with production hardening and a temporary staging key;
+4. completes HTTP health, registration, browse, authenticated UDP mediation,
+ and direct traffic;
+5. rejects all high or critical container findings and emits a container SPDX
+ inventory;
+6. finalizes and verifies checksums over the publish-ready artifact set; and
+7. confirms the two NuGet versions, container version, and Gitea release do not
+ already exist.
+
+Publication has no skip-duplicate behavior. Gitea's immutable package versions,
+the workflow concurrency lock, and the preflight make a successful tag a
+single publication event. The workflow pushes symbols, publishes only the
+versioned container tag, records its `sha256` digest in both a digest file and
+provenance, regenerates the checksum manifest so that digest and public key are
+covered, signs the checksum manifest and image, attaches signed provenance,
+verifies the complete published-set schema and all signatures, and creates the
+Gitea release with exactly those artifacts. The detached checksum signature
+bundle is the sole envelope excluded from its own signed manifest.
+The loaded image ID is captured immediately after the byte-reproducible build;
+publication refuses to push if staging or another process retagged that local
+name to different bytes.
+
+The protected `production` environment requires these secrets:
+
+- `RELEASE_TOKEN`: a dedicated Gitea token limited to this repository and the
+ HeiKyu package registry, with repository and package write access;
+- `RELEASE_USERNAME`: the dedicated Gitea service-account name that owns the
+ release token;
+- `COSIGN_PRIVATE_KEY`: the encrypted Cosign release private key; and
+- `COSIGN_PASSWORD`: its password, stored separately.
+
+No development signing key, registry credential, or deployable configuration
+is stored in source or packages. Keep the Cosign public key with operational
+records. Rotate the release key between releases: retain the old public key for
+historical verification, install the new encrypted private key and password as
+one reviewed change, verify a signed non-release blob, and only then retire the
+old secret. Suspected compromise requires token/key revocation and a new
+version; never overwrite or delete evidence to reuse a released version.
+
+## Release notes and migration
+
+Every dated `CHANGELOG.md` entry must contain Compatibility, Security and
+configuration, and Migration sections. Before tagging, state the supported
+Client/server window, all HTTP/UDP/ticket changes, security fixes, required
+configuration, and operator/consumer migration steps.
+
+For a protocol migration, first make the server read both old and new versions
+within an explicit bounded window, publish a Client that writes the new version,
+verify adoption through bounded telemetry, then remove the old reader only in a
+subsequent breaking release. Never silently reinterpret old bytes. Consumers
+pin both Rendezvous packages to one exact version and choose gameplay protocol
+compatibility per tenant.
+
+## Rollback and interrupted publication
+
+Runtime rollback means redeploying the previous known-good image by digest and
+its matching configuration; it does not move a tag. Packages and release
+records remain available so already restored clients stay reproducible. If a
+new release is faulty, revoke affected publisher or signing keys when relevant,
+mark the release notes as withdrawn, and publish the fix under a new SemVer.
+
+The registries cannot provide a transaction spanning NuGet, OCI, signatures,
+and release attachments. If publication stops after its first external write,
+the preflight intentionally prevents an automatic rerun. An operator must
+inventory every destination, preserve logs and hashes, complete or withdraw the
+partial version under change control, and then issue a new version. This avoids
+turning a partial failure into an untraceable overwrite.
diff --git a/docs/releases/compatibility.json b/docs/releases/compatibility.json
new file mode 100644
index 0000000..6d3122d
--- /dev/null
+++ b/docs/releases/compatibility.json
@@ -0,0 +1,27 @@
+{
+ "schemaVersion": 1,
+ "release": "1.0.0",
+ "server": {
+ "minimumClientVersion": "1.0.0",
+ "maximumClientMajorVersion": 1
+ },
+ "packages": {
+ "FinalFactory.Rendezvous.Client": "1.0.0",
+ "FinalFactory.Rendezvous.Contracts": "1.0.0"
+ },
+ "contracts": {
+ "http": [1],
+ "udp": [1],
+ "connectionTicket": [1],
+ "gameplay": "exact-per-tenant"
+ },
+ "transport": {
+ "package": "LiteNetLib",
+ "version": "2.1.4",
+ "major": 2
+ },
+ "consumers": {
+ "SpaceGame": "net8.0",
+ "Unscouted": "net8.0"
+ }
+}
diff --git a/eng/Versions.props b/eng/Versions.props
new file mode 100644
index 0000000..3cdbe30
--- /dev/null
+++ b/eng/Versions.props
@@ -0,0 +1,15 @@
+
+
+ 1.0.0
+ 1
+ 0
+ 0
+ 1.0.0
+ 1
+ 1
+ 1
+ 1
+ 2.1.4
+ 2
+
+
diff --git a/eng/consumer-revisions.json b/eng/consumer-revisions.json
new file mode 100644
index 0000000..f39f0ed
--- /dev/null
+++ b/eng/consumer-revisions.json
@@ -0,0 +1,17 @@
+{
+ "schemaVersion": 1,
+ "consumers": [
+ {
+ "name": "SpaceGame",
+ "repository": "https://git.finalfactory.de/Kyuubi/SpaceGame.git",
+ "revision": "77519b0cc418a27f8d408ae2d7b8812fbe087c04",
+ "project": "SpaceGame.csproj"
+ },
+ {
+ "name": "Unscouted",
+ "repository": "https://git.finalfactory.de/HeiKyu/Unscouted.git",
+ "revision": "7807dbee86eb8b98e702f1eb89c88adff728f635",
+ "project": "Net.Core/Net.Core.csproj"
+ }
+ ]
+}
diff --git a/eng/release-builder.Dockerfile b/eng/release-builder.Dockerfile
new file mode 100644
index 0000000..41c3df4
--- /dev/null
+++ b/eng/release-builder.Dockerfile
@@ -0,0 +1,14 @@
+# syntax=docker/dockerfile:1.7@sha256:a57df69d0ea827fb7266491f2813635de6f17269be881f696fbfdf2d83dda33e
+FROM python:3.12.11-slim-bookworm@sha256:c00fc7b44d844b6da22861ec24af43968a5200eac4ec607b4725d585165d6b49 AS release-python
+FROM ghcr.io/jqlang/jq:1.8.1@sha256:95de8f005ca027686a1ca3b0853e2bb219062438015862816159f3f25a4d4230 AS release-jq
+
+FROM mcr.microsoft.com/dotnet/sdk:10.0.301-noble@sha256:ea8bde36c11b6e7eec2656d0e59101d4462f6bd630730f2c8201ed0572b295d5 AS release-builder
+COPY --from=release-python /usr/local/ /usr/local/
+COPY --from=release-jq /jq /usr/local/bin/jq
+RUN dotnet --version \
+ && python3 --version \
+ && git --version \
+ && tar --version \
+ && gzip --version \
+ && jq --version
+WORKDIR /source
diff --git a/eng/release-policy.json b/eng/release-policy.json
new file mode 100644
index 0000000..ae6f076
--- /dev/null
+++ b/eng/release-policy.json
@@ -0,0 +1,27 @@
+{
+ "schemaVersion": 1,
+ "packageRegistry": "https://git.finalfactory.de/api/packages/HeiKyu/nuget/index.json",
+ "containerRepository": "git.finalfactory.de/heikyu/rendezvous",
+ "allowedLicenseExpressions": [
+ "Apache-2.0",
+ "BSD-2-Clause",
+ "BSD-3-Clause",
+ "MIT"
+ ],
+ "dependencyLicenseOverrides": {
+ "xunit.abstractions/2.0.3": {
+ "license": "Apache-2.0",
+ "reason": "Legacy package predates NuGet SPDX metadata; reviewed against the xUnit.net Apache-2.0 license."
+ }
+ },
+ "publishedPackages": [
+ "FinalFactory.Rendezvous.Client",
+ "FinalFactory.Rendezvous.Contracts"
+ ],
+ "forbiddenArtifactNameFragments": [
+ "credential",
+ "password",
+ "private-key",
+ "signing-key"
+ ]
+}
diff --git a/eng/release_artifacts.py b/eng/release_artifacts.py
new file mode 100644
index 0000000..d53252a
--- /dev/null
+++ b/eng/release_artifacts.py
@@ -0,0 +1,823 @@
+#!/usr/bin/env python3
+import argparse
+import datetime as dt
+import hashlib
+import json
+import os
+import pathlib
+import re
+import sys
+import zipfile
+import xml.etree.ElementTree as ET
+from xml.sax.saxutils import escape
+
+
+PROJECT_PACKAGE_PREFIX = "finalfactory.rendezvous."
+CORE_PROPERTIES_PATH = (
+ "package/services/metadata/core-properties/core-properties.psmdcp"
+)
+
+
+def fail(message: str) -> None:
+ raise SystemExit(message)
+
+
+def load_json(path: pathlib.Path):
+ with path.open(encoding="utf-8") as stream:
+ return json.load(stream)
+
+
+def dependency_inventory(root: pathlib.Path):
+ dependencies = {}
+ for lock_path in sorted(root.glob("**/packages.lock.json")):
+ if any(part in {"bin", "obj", "artifacts"} for part in lock_path.parts):
+ continue
+ lock = load_json(lock_path)
+ for framework in lock.get("dependencies", {}).values():
+ for package_id, details in framework.items():
+ resolved = details.get("resolved")
+ if not resolved or package_id.lower().startswith(PROJECT_PACKAGE_PREFIX):
+ continue
+ key = package_id.lower()
+ previous = dependencies.get(key)
+ if previous is not None and previous[1] != resolved:
+ fail(
+ f"Dependency {package_id} resolves to both {previous[1]} and {resolved}."
+ )
+ dependencies[key] = (package_id, resolved)
+ return [dependencies[key] for key in sorted(dependencies)]
+
+
+def package_license(package_id: str, version: str, overrides):
+ package_root = pathlib.Path(
+ os.environ.get("NUGET_PACKAGES", pathlib.Path.home() / ".nuget" / "packages")
+ )
+ version_dir = package_root / package_id.lower() / version
+ nuspecs = list(version_dir.glob("*.nuspec"))
+ if len(nuspecs) != 1:
+ fail(f"Expected one restored nuspec for {package_id} {version} in {version_dir}.")
+ root = ET.parse(nuspecs[0]).getroot()
+ license_element = root.find(".//{*}license")
+ if license_element is None or license_element.get("type") != "expression":
+ override = overrides.get(f"{package_id.lower()}/{version}")
+ if override is None:
+ fail(f"{package_id} {version} does not declare an SPDX license expression.")
+ return override["license"]
+ expression = (license_element.text or "").strip()
+ if not expression:
+ fail(f"{package_id} {version} has an empty license expression.")
+ return expression
+
+
+def command_policy(args) -> None:
+ root = pathlib.Path(args.root).resolve()
+ policy = load_json(root / "eng" / "release-policy.json")
+ allowed = set(policy["allowedLicenseExpressions"])
+ inventory = dependency_inventory(root)
+ observed = []
+ for package_id, version in inventory:
+ expression = package_license(
+ package_id, version, policy.get("dependencyLicenseOverrides", {})
+ )
+ if expression not in allowed:
+ fail(
+ f"Dependency {package_id} {version} uses unapproved license {expression}."
+ )
+ observed.append({"id": package_id, "version": version, "license": expression})
+ lite_net_lib = [item for item in observed if item["id"].lower() == "litenetlib"]
+ if lite_net_lib != [{"id": "LiteNetLib", "version": "2.1.4", "license": "MIT"}]:
+ fail(f"LiteNetLib must resolve exactly to the reviewed 2.1.4 release: {lite_net_lib}")
+ print(json.dumps({"dependencies": observed}, indent=2))
+
+
+def command_audit(args) -> None:
+ document = load_json(pathlib.Path(args.input))
+ findings = []
+
+ def visit(value):
+ if isinstance(value, dict):
+ if value.get("vulnerabilities"):
+ findings.append(value)
+ for child in value.values():
+ visit(child)
+ elif isinstance(value, list):
+ for child in value:
+ visit(child)
+
+ visit(document)
+ if findings:
+ fail(f"Locked dependency graph contains known vulnerabilities: {findings}")
+ print("Locked dependency graph has no reported vulnerabilities.")
+
+
+def release_dependency_graph(root: pathlib.Path, server_deps: pathlib.Path):
+ dependencies = {}
+ edges = set()
+ server_document = load_json(server_deps)
+ for package_key, details in server_document.get("libraries", {}).items():
+ if details.get("type") != "package":
+ continue
+ package_id, version = package_key.rsplit("/", 1)
+ dependencies[package_id.lower()] = (package_id, version)
+ server_target = next(iter(server_document.get("targets", {}).values()), {})
+ for source_key, details in server_target.items():
+ source_id = source_key.rsplit("/", 1)[0]
+ source = (
+ "SPDXRef-Server"
+ if source_id == "FinalFactory.Rendezvous.Server"
+ else source_id.lower()
+ )
+ for dependency_id in details.get("dependencies", {}):
+ target = {
+ "FinalFactory.Rendezvous.Contracts": "SPDXRef-Contracts",
+ "FinalFactory.Rendezvous.Client": "SPDXRef-Client",
+ }.get(dependency_id, dependency_id.lower())
+ edges.add((source, target))
+
+ lock_roots = (
+ ("SPDXRef-Client", root / "src/FinalFactory.Rendezvous.Client/packages.lock.json"),
+ (
+ "SPDXRef-Contracts",
+ root / "src/FinalFactory.Rendezvous.Contracts/packages.lock.json",
+ ),
+ )
+ for root_id, lock_path in lock_roots:
+ lock = load_json(lock_path)
+ for framework in lock.get("dependencies", {}).values():
+ for package_id, details in framework.items():
+ resolved = details.get("resolved")
+ if resolved and not package_id.lower().startswith(PROJECT_PACKAGE_PREFIX):
+ dependencies[package_id.lower()] = (package_id, resolved)
+ if details.get("type") == "Direct":
+ edges.add((root_id, package_id.lower()))
+ source = package_id.lower()
+ for dependency_id in details.get("dependencies", {}):
+ if dependency_id.lower().startswith(PROJECT_PACKAGE_PREFIX):
+ continue
+ edges.add((source, dependency_id.lower()))
+ edges.add(("SPDXRef-Client", "SPDXRef-Contracts"))
+ inventory = [dependencies[key] for key in sorted(dependencies)]
+ return inventory, edges
+
+
+def command_sbom(args) -> None:
+ root = pathlib.Path(args.root).resolve()
+ policy = load_json(root / "eng" / "release-policy.json")
+ overrides = policy.get("dependencyLicenseOverrides", {})
+ packages = [
+ {
+ "SPDXID": "SPDXRef-Rendezvous",
+ "name": "FinalFactory.Rendezvous",
+ "versionInfo": args.version,
+ "downloadLocation": "NOASSERTION",
+ "filesAnalyzed": False,
+ "licenseConcluded": "NOASSERTION",
+ "licenseDeclared": "NOASSERTION",
+ "copyrightText": "NOASSERTION",
+ }
+ ]
+ internal_packages = [
+ ("SPDXRef-Server", "FinalFactory.Rendezvous.Server"),
+ ("SPDXRef-Client", "FinalFactory.Rendezvous.Client"),
+ ("SPDXRef-Contracts", "FinalFactory.Rendezvous.Contracts"),
+ ]
+ for spdx_id, package_id in internal_packages:
+ packages.append(
+ {
+ "SPDXID": spdx_id,
+ "name": package_id,
+ "versionInfo": args.version,
+ "downloadLocation": "NOASSERTION",
+ "filesAnalyzed": False,
+ "licenseConcluded": "NOASSERTION",
+ "licenseDeclared": "NOASSERTION",
+ "copyrightText": "NOASSERTION",
+ }
+ )
+ inventory, dependency_edges = release_dependency_graph(
+ root, pathlib.Path(args.server_deps)
+ )
+ dependency_ids = {}
+ for index, (package_id, version) in enumerate(
+ inventory, start=1
+ ):
+ expression = package_license(package_id, version, overrides)
+ spdx_id = f"SPDXRef-Package-{index}"
+ dependency_ids[package_id.lower()] = spdx_id
+ packages.append(
+ {
+ "SPDXID": spdx_id,
+ "name": package_id,
+ "versionInfo": version,
+ "downloadLocation": "NOASSERTION",
+ "filesAnalyzed": False,
+ "licenseConcluded": expression,
+ "licenseDeclared": expression,
+ "copyrightText": "NOASSERTION",
+ "externalRefs": [
+ {
+ "referenceCategory": "PACKAGE-MANAGER",
+ "referenceType": "purl",
+ "referenceLocator": f"pkg:nuget/{package_id}@{version}",
+ }
+ ],
+ }
+ )
+ created = dt.datetime.fromtimestamp(
+ int(args.source_date_epoch), dt.timezone.utc
+ ).strftime("%Y-%m-%dT%H:%M:%SZ")
+ document = {
+ "spdxVersion": "SPDX-2.3",
+ "dataLicense": "CC0-1.0",
+ "SPDXID": "SPDXRef-DOCUMENT",
+ "name": f"FinalFactory.Rendezvous-{args.version}",
+ "documentNamespace": (
+ "https://git.finalfactory.de/HeiKyu/Rendezvous/sbom/"
+ f"{args.version}/{args.commit}"
+ ),
+ "creationInfo": {
+ "created": created,
+ "creators": ["Tool: eng/release_artifacts.py"],
+ },
+ "documentDescribes": ["SPDXRef-Rendezvous"],
+ "packages": packages,
+ "relationships": [
+ {
+ "spdxElementId": "SPDXRef-Rendezvous",
+ "relationshipType": "CONTAINS",
+ "relatedSpdxElement": spdx_id,
+ }
+ for spdx_id, _ in internal_packages
+ ]
+ + [
+ {
+ "spdxElementId": dependency_ids.get(source, source),
+ "relationshipType": "DEPENDS_ON",
+ "relatedSpdxElement": dependency_ids.get(target, target),
+ }
+ for source, target in sorted(dependency_edges)
+ if source in dependency_ids or source.startswith("SPDXRef-")
+ if target in dependency_ids or target.startswith("SPDXRef-")
+ ],
+ }
+ output = pathlib.Path(args.output)
+ output.parent.mkdir(parents=True, exist_ok=True)
+ output.write_text(json.dumps(document, indent=2) + "\n", encoding="utf-8")
+
+
+def nuspec_metadata(archive: pathlib.Path):
+ with zipfile.ZipFile(archive) as package:
+ names = package.namelist()
+ nuspecs = [name for name in names if name.endswith(".nuspec")]
+ if len(nuspecs) != 1:
+ fail(f"{archive.name} must contain exactly one nuspec.")
+ root = ET.fromstring(package.read(nuspecs[0]))
+ metadata = root.find(".//{*}metadata")
+ if metadata is None:
+ fail(f"{archive.name} has no package metadata.")
+ values = {
+ child.tag.rsplit("}", 1)[-1]: (child.text or "").strip()
+ for child in metadata
+ if len(child) == 0
+ }
+ dependencies = {
+ item.get("id"): item.get("version")
+ for item in metadata.findall(".//{*}dependency")
+ }
+ repository = metadata.find("./{*}repository")
+ repository_attributes = {} if repository is None else dict(repository.attrib)
+ return values, dependencies, repository_attributes
+
+
+def verify_checksum_file(release_dir: pathlib.Path, excluded=()) -> None:
+ checksum_path = release_dir / "checksums.sha256"
+ lines = checksum_path.read_text(encoding="utf-8").splitlines()
+ if not lines:
+ fail("checksums.sha256 is empty.")
+ referenced = set()
+ for line in lines:
+ digest, marker, relative = line.partition(" ")
+ if marker != " " or not re.fullmatch(r"[0-9a-f]{64}", digest):
+ fail(f"Malformed checksum line: {line}")
+ target = release_dir / relative
+ if not target.is_file():
+ fail(f"Checksum references missing artifact: {relative}")
+ actual = hashlib.sha256(target.read_bytes()).hexdigest()
+ if actual != digest:
+ fail(f"Checksum mismatch for {relative}.")
+ referenced.add(relative)
+ expected = {
+ path.name
+ for path in release_dir.iterdir()
+ if path.is_file()
+ and path.name != "checksums.sha256"
+ and path.name not in excluded
+ }
+ if referenced != expected:
+ fail(
+ "Checksum manifest coverage differs. "
+ f"Missing={expected - referenced}; extra={referenced - expected}"
+ )
+
+
+def command_verify(args) -> None:
+ release_dir = pathlib.Path(args.release_dir).resolve()
+ version = args.version
+ expected = {
+ f"FinalFactory.Rendezvous.Client.{version}.nupkg",
+ f"FinalFactory.Rendezvous.Client.{version}.snupkg",
+ f"FinalFactory.Rendezvous.Contracts.{version}.nupkg",
+ f"FinalFactory.Rendezvous.Contracts.{version}.snupkg",
+ f"FinalFactory.Rendezvous.Server.{version}.linux-x64.tar.gz",
+ f"FinalFactory.Rendezvous.{version}.spdx.json",
+ "CHANGELOG.md",
+ "checksums.sha256",
+ "release-provenance.json",
+ }
+ checksum_exclusions = set()
+ if args.phase in {"publish-ready", "signing-ready", "published"}:
+ expected.add(f"FinalFactory.Rendezvous.Container.{version}.spdx.json")
+ if args.phase in {"signing-ready", "published"}:
+ expected.update({"container-digest.txt", "cosign.pub"})
+ if args.phase == "published":
+ expected.add("checksums.sha256.bundle")
+ checksum_exclusions.add("checksums.sha256.bundle")
+ actual = {path.name for path in release_dir.iterdir() if path.is_file()}
+ if actual != expected:
+ fail(f"Release artifact set differs. Missing={expected - actual}; extra={actual - expected}")
+
+ if args.phase in {"publish-ready", "signing-ready", "published"}:
+ container_sbom = load_json(
+ release_dir / f"FinalFactory.Rendezvous.Container.{version}.spdx.json"
+ )
+ if container_sbom.get("spdxVersion") != "SPDX-2.3":
+ fail("Container inventory must be an SPDX 2.3 document.")
+ if not container_sbom.get("packages"):
+ fail("Container SPDX inventory contains no packages.")
+
+ policy = load_json(pathlib.Path(args.root) / "eng" / "release-policy.json")
+ forbidden = tuple(fragment.lower() for fragment in policy["forbiddenArtifactNameFragments"])
+ for artifact in actual:
+ if artifact != "release-provenance.json" and any(fragment in artifact.lower() for fragment in forbidden):
+ fail(f"Forbidden secret-like artifact name: {artifact}")
+
+ release_sbom = load_json(
+ release_dir / f"FinalFactory.Rendezvous.{version}.spdx.json"
+ )
+ package_ids = {
+ package.get("name"): package.get("SPDXID")
+ for package in release_sbom.get("packages", [])
+ }
+ relationships = {
+ (
+ relationship.get("spdxElementId"),
+ relationship.get("relationshipType"),
+ relationship.get("relatedSpdxElement"),
+ )
+ for relationship in release_sbom.get("relationships", [])
+ }
+ expected_component_edges = {
+ ("SPDXRef-Server", "SPDXRef-Contracts"),
+ ("SPDXRef-Server", package_ids.get("LiteNetLib")),
+ ("SPDXRef-Server", package_ids.get("Microsoft.AspNetCore.OpenApi")),
+ ("SPDXRef-Client", "SPDXRef-Contracts"),
+ ("SPDXRef-Client", package_ids.get("LiteNetLib")),
+ ("SPDXRef-Contracts", package_ids.get("System.Text.Json")),
+ }
+ for source, target in expected_component_edges:
+ if not target or (source, "DEPENDS_ON", target) not in relationships:
+ fail(f"Release SPDX inventory is missing component edge {source} -> {target}.")
+ bcl_id = package_ids.get("Microsoft.Bcl.AsyncInterfaces")
+ if ("SPDXRef-Server", "DEPENDS_ON", bcl_id) in relationships:
+ fail("Release SPDX inventory incorrectly flattens transitive dependencies onto Server.")
+
+ for package_id in policy["publishedPackages"]:
+ package = release_dir / f"{package_id}.{version}.nupkg"
+ metadata, dependencies, repository = nuspec_metadata(package)
+ if metadata.get("id") != package_id or metadata.get("version") != version:
+ fail(f"{package.name} identity/version metadata is incorrect.")
+ if metadata.get("projectUrl") != "https://git.finalfactory.de/HeiKyu/Rendezvous":
+ fail(f"{package.name} has an incorrect project URL.")
+ if repository.get("url") != "https://git.finalfactory.de/HeiKyu/Rendezvous":
+ fail(f"{package.name} has an incorrect repository URL.")
+ if repository.get("commit") != provenance_commit(release_dir):
+ fail(f"{package.name} does not identify the release commit.")
+ symbol_package = release_dir / f"{package_id}.{version}.snupkg"
+ with zipfile.ZipFile(symbol_package) as symbols:
+ if not any(name.endswith(".pdb") for name in symbols.namelist()):
+ fail(f"{symbol_package.name} contains no portable PDB.")
+ with zipfile.ZipFile(package) as archive:
+ names = set(archive.namelist())
+ required_entries = {
+ "README.md",
+ "CHANGELOG.md",
+ f"lib/netstandard2.1/{package_id}.dll",
+ }
+ if not required_entries <= names:
+ fail(
+ f"{package.name} is missing required package content: "
+ f"{required_entries - names}"
+ )
+ forbidden_entries = [
+ name
+ for name in names
+ if any(
+ fragment in name.lower()
+ for fragment in (
+ "appsettings",
+ "launchsettings",
+ ".env",
+ "credential",
+ "signing-key",
+ "private-key",
+ )
+ )
+ ]
+ if forbidden_entries:
+ fail(
+ f"{package.name} contains deployable configuration or secrets: "
+ f"{forbidden_entries}"
+ )
+ if package_id.endswith(".Client"):
+ if dependencies.get("LiteNetLib") != "[2.1.4]":
+ fail(f"Client package must pin LiteNetLib exactly to 2.1.4: {dependencies}")
+ contracts_range = dependencies.get("FinalFactory.Rendezvous.Contracts", "")
+ if contracts_range != f"[{version}]":
+ fail(f"Client package does not depend on the matching Contracts version.")
+
+ provenance = load_json(release_dir / "release-provenance.json")
+ if provenance.get("version") != version:
+ fail("Release provenance does not identify the requested version.")
+ if provenance.get("treeState") != "clean" and not args.allow_dirty:
+ fail("Release provenance must identify a clean tree.")
+ container = provenance.get("containerImage", "")
+ if container.endswith(":latest") or ":latest@" in container or f":{version}" not in container:
+ fail(f"Container reference is mutable or not versioned: {container}")
+ if provenance.get("containerPlatform") != "linux/amd64":
+ fail("Release provenance must pin the linux/amd64 container platform.")
+ for field in ("containerBaseDigests", "releaseBuilderBaseDigests"):
+ images = provenance.get(field, [])
+ if not images or any("@sha256:" not in image or image.endswith(":latest") for image in images):
+ fail(f"Release provenance contains an unpinned build image in {field}: {images}")
+ build_tools = provenance.get("buildTools", [])
+ required_tool_prefixes = ("dotnet", "python=", "tar=", "gzip=", "jq=")
+ observed_tools = [f"dotnet={provenance.get('dotnetSdk', '')}", *build_tools]
+ for prefix in required_tool_prefixes:
+ if not any(tool.startswith(prefix) for tool in observed_tools):
+ fail(f"Release provenance is missing an artifact tool version: {prefix}")
+ if args.phase in {"publish-ready", "signing-ready", "published"}:
+ if not re.fullmatch(
+ r"sha256:[0-9a-f]{64}", provenance.get("containerImageId", "")
+ ):
+ fail("Release provenance is missing the verified local container image ID.")
+ expected_namespace = (
+ "https://git.finalfactory.de/HeiKyu/Rendezvous/container-sbom/"
+ f"{version}/{provenance_commit(release_dir)}"
+ )
+ if container_sbom.get("documentNamespace") != expected_namespace:
+ fail("Container SPDX inventory does not identify the release commit.")
+ expected_created = dt.datetime.fromtimestamp(
+ int(provenance.get("sourceDateEpoch", 0)), dt.timezone.utc
+ ).strftime("%Y-%m-%dT%H:%M:%SZ")
+ if container_sbom.get("creationInfo", {}).get("created") != expected_created:
+ fail("Container SPDX timestamp is not normalized to the source epoch.")
+ if args.phase in {"signing-ready", "published"}:
+ digest = (release_dir / "container-digest.txt").read_text(
+ encoding="utf-8"
+ ).strip()
+ if not re.fullmatch(
+ r"git\.finalfactory\.de/heikyu/rendezvous@sha256:[0-9a-f]{64}", digest
+ ):
+ fail(f"Published container digest is invalid: {digest}")
+ if provenance.get("containerDigest") != digest:
+ fail("Published provenance and container digest file differ.")
+ for prefix in ("docker-buildx=", "buildkit="):
+ if not any(tool.startswith(prefix) for tool in build_tools):
+ fail(f"Published provenance is missing container tool version: {prefix}")
+ verify_checksum_file(release_dir, checksum_exclusions)
+ print(f"Verified {args.phase} release artifact set for {version}.")
+
+
+def provenance_commit(release_dir: pathlib.Path) -> str:
+ provenance = load_json(release_dir / "release-provenance.json")
+ commit = provenance.get("commit", "")
+ if not re.fullmatch(r"[0-9a-f]{40}", commit):
+ fail("Release provenance must contain a full Git commit SHA.")
+ return commit
+
+
+def command_consumer(args) -> None:
+ assets = load_json(pathlib.Path(args.assets))
+ libraries = assets.get("libraries", {})
+ required = {
+ f"FinalFactory.Rendezvous.Client/{args.version}",
+ f"FinalFactory.Rendezvous.Contracts/{args.version}",
+ "LiteNetLib/2.1.4",
+ }
+ missing = required - set(libraries)
+ if missing:
+ fail(f"Consumer restore is missing exact release dependencies: {missing}")
+ forbidden = [name for name in libraries if name.lower().startswith("litenetlib/1.")]
+ if forbidden:
+ fail(f"Consumer resolved forbidden LiteNetLib 1.x assets: {forbidden}")
+
+
+def command_consumer_config(args) -> None:
+ local_source = escape(str(pathlib.Path(args.local_source).resolve()))
+ configuration = f'''
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+'''
+ pathlib.Path(args.output).write_text(configuration, encoding="utf-8")
+
+
+def command_source_link(args) -> None:
+ document = load_json(pathlib.Path(args.file))
+ mappings = document.get("documents", {})
+ expected = (
+ "https://git.finalfactory.de/HeiKyu/Rendezvous/raw/commit/"
+ f"{args.commit}/"
+ )
+ if not mappings or any(not value.startswith(expected) for value in mappings.values()):
+ fail(f"SourceLink mappings do not identify commit {args.commit}: {mappings}")
+
+
+def command_normalize_package(args) -> None:
+ package_path = pathlib.Path(args.package).resolve()
+ if package_path.suffix not in {".nupkg", ".snupkg"}:
+ fail(f"Unsupported NuGet archive extension: {package_path.name}")
+ timestamp = dt.datetime.fromtimestamp(
+ int(args.source_date_epoch), dt.timezone.utc
+ )
+ zip_timestamp = (
+ max(timestamp.year, 1980),
+ timestamp.month,
+ timestamp.day,
+ timestamp.hour,
+ timestamp.minute,
+ timestamp.second - (timestamp.second % 2),
+ )
+ with zipfile.ZipFile(package_path) as source:
+ entries = {name: source.read(name) for name in source.namelist()}
+ if ".signature.p7s" in entries:
+ fail(f"Refusing to rewrite signed package: {package_path.name}")
+ core_paths = [
+ name
+ for name in entries
+ if name.startswith("package/services/metadata/core-properties/")
+ and name.endswith(".psmdcp")
+ ]
+ if len(core_paths) != 1:
+ fail(f"Expected one NuGet core-properties part in {package_path.name}.")
+ entries[CORE_PROPERTIES_PATH] = entries.pop(core_paths[0])
+
+ nuspec_paths = [name for name in entries if name.endswith(".nuspec")]
+ if len(nuspec_paths) != 1:
+ fail(f"Expected one nuspec in {package_path.name}.")
+ nuspec = ET.fromstring(entries[nuspec_paths[0]])
+ nuspec_namespace = nuspec.tag.partition("}")[0].removeprefix("{")
+ if nuspec_namespace:
+ ET.register_namespace("", nuspec_namespace)
+ package_id = nuspec.findtext(".//{*}id")
+ if package_id == "FinalFactory.Rendezvous.Client":
+ contracts = nuspec.find(
+ ".//{*}dependency[@id='FinalFactory.Rendezvous.Contracts']"
+ )
+ if contracts is None:
+ fail("Client package has no Contracts dependency to pin.")
+ contracts.set("version", f"[{args.version}]")
+ entries[nuspec_paths[0]] = ET.tostring(
+ nuspec, encoding="utf-8", xml_declaration=True
+ )
+
+ relationships_namespace = (
+ "http://schemas.openxmlformats.org/package/2006/relationships"
+ )
+ ET.register_namespace("", relationships_namespace)
+ relationships = ET.fromstring(entries["_rels/.rels"])
+ for relationship in relationships:
+ relationship_type = relationship.get("Type", "")
+ if relationship_type.endswith("/manifest"):
+ relationship.set("Id", "RManifest")
+ elif relationship_type.endswith("/metadata/core-properties"):
+ relationship.set("Id", "RCoreProperties")
+ relationship.set("Target", f"/{CORE_PROPERTIES_PATH}")
+ entries["_rels/.rels"] = ET.tostring(
+ relationships, encoding="utf-8", xml_declaration=True
+ )
+
+ temporary = package_path.with_suffix(package_path.suffix + ".normalized")
+ with zipfile.ZipFile(temporary, "w", compression=zipfile.ZIP_STORED) as target:
+ for name in sorted(entries):
+ info = zipfile.ZipInfo(name, date_time=zip_timestamp)
+ info.compress_type = zipfile.ZIP_STORED
+ info.create_system = 3
+ info.external_attr = 0o100644 << 16
+ target.writestr(info, entries[name])
+ temporary.replace(package_path)
+
+
+def command_provenance(args) -> None:
+ versions = ET.parse(pathlib.Path(args.root) / "eng" / "Versions.props")
+
+ def version_property(name: str) -> str:
+ element = versions.find(f".//{name}")
+ if element is None or not element.text:
+ fail(f"Missing central release property: {name}")
+ return element.text.strip()
+
+ provenance = {
+ "schemaVersion": 1,
+ "version": args.version,
+ "commit": args.commit,
+ "treeState": args.tree_state,
+ "buildConfiguration": "Release",
+ "sourceDateEpoch": int(args.source_date_epoch),
+ "dotnetSdk": args.dotnet_sdk,
+ "buildTools": sorted(args.build_tool),
+ "containerImage": f"git.finalfactory.de/heikyu/rendezvous:{args.version}",
+ "containerPlatform": "linux/amd64",
+ "containerBaseDigests": args.base_digest,
+ "releaseBuilderBaseDigests": args.builder_base,
+ "packages": [
+ f"FinalFactory.Rendezvous.Client/{args.version}",
+ f"FinalFactory.Rendezvous.Contracts/{args.version}",
+ ],
+ "compatibility": {
+ "minimumClientVersion": version_property("MinimumClientVersion"),
+ "maximumClientMajorVersion": int(version_property("MaximumClientMajorVersion")),
+ "httpContractVersions": [int(version_property("HttpContractVersion"))],
+ "udpContractVersions": [int(version_property("UdpContractVersion"))],
+ "connectionTicketFormatVersions": [
+ int(version_property("ConnectionTicketFormatVersion"))
+ ],
+ "liteNetLib": version_property("LiteNetLibVersion"),
+ "gameplayProtocol": "exact-per-tenant",
+ },
+ }
+ pathlib.Path(args.output).write_text(
+ json.dumps(provenance, indent=2) + "\n", encoding="utf-8"
+ )
+
+
+def command_record_container_build(args) -> None:
+ path = pathlib.Path(args.provenance)
+ provenance = load_json(path)
+ tools = set(provenance.get("buildTools", []))
+ tools.add(f"docker-buildx={args.buildx_version}")
+ tools.add(f"buildkit={args.buildkit_version}")
+ provenance["buildTools"] = sorted(tools)
+ provenance["containerPlatform"] = "linux/amd64"
+ if not re.fullmatch(r"sha256:[0-9a-f]{64}", args.image_id):
+ fail(f"Container image ID is invalid: {args.image_id}")
+ provenance["containerImageId"] = args.image_id
+ path.write_text(json.dumps(provenance, indent=2) + "\n", encoding="utf-8")
+
+
+def command_record_container_digest(args) -> None:
+ if not re.fullmatch(
+ r"git\.finalfactory\.de/heikyu/rendezvous@sha256:[0-9a-f]{64}",
+ args.digest,
+ ):
+ fail(f"Published container digest is invalid: {args.digest}")
+ release_dir = pathlib.Path(args.release_dir)
+ provenance_path = release_dir / "release-provenance.json"
+ provenance = load_json(provenance_path)
+ provenance["containerDigest"] = args.digest
+ provenance_path.write_text(
+ json.dumps(provenance, indent=2) + "\n", encoding="utf-8"
+ )
+ (release_dir / "container-digest.txt").write_text(
+ args.digest + "\n", encoding="utf-8"
+ )
+
+
+def command_normalize_container_sbom(args) -> None:
+ path = pathlib.Path(args.file)
+ document = load_json(path)
+ created = dt.datetime.fromtimestamp(
+ int(args.source_date_epoch), dt.timezone.utc
+ ).strftime("%Y-%m-%dT%H:%M:%SZ")
+ document["name"] = f"FinalFactory.Rendezvous.Container-{args.version}"
+ document["documentNamespace"] = (
+ "https://git.finalfactory.de/HeiKyu/Rendezvous/container-sbom/"
+ f"{args.version}/{args.commit}"
+ )
+ creation = document.setdefault("creationInfo", {})
+ creation["created"] = created
+ creation["creators"] = ["Tool: Trivy-0.69.3"]
+ if isinstance(document.get("packages"), list):
+ document["packages"] = sorted(
+ document["packages"],
+ key=lambda item: (
+ item.get("SPDXID", ""),
+ item.get("name", ""),
+ item.get("versionInfo", ""),
+ ),
+ )
+ if isinstance(document.get("relationships"), list):
+ document["relationships"] = sorted(
+ document["relationships"],
+ key=lambda item: (
+ item.get("spdxElementId", ""),
+ item.get("relationshipType", ""),
+ item.get("relatedSpdxElement", ""),
+ ),
+ )
+ path.write_text(
+ json.dumps(document, indent=2, sort_keys=True) + "\n", encoding="utf-8"
+ )
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser()
+ subparsers = parser.add_subparsers(dest="command", required=True)
+ policy = subparsers.add_parser("policy")
+ policy.add_argument("--root", required=True)
+ policy.set_defaults(handler=command_policy)
+ audit = subparsers.add_parser("audit")
+ audit.add_argument("--input", required=True)
+ audit.set_defaults(handler=command_audit)
+ sbom = subparsers.add_parser("sbom")
+ sbom.add_argument("--root", required=True)
+ sbom.add_argument("--version", required=True)
+ sbom.add_argument("--commit", required=True)
+ sbom.add_argument("--source-date-epoch", required=True)
+ sbom.add_argument("--server-deps", required=True)
+ sbom.add_argument("--output", required=True)
+ sbom.set_defaults(handler=command_sbom)
+ verify = subparsers.add_parser("verify")
+ verify.add_argument("--root", required=True)
+ verify.add_argument("--release-dir", required=True)
+ verify.add_argument("--version", required=True)
+ verify.add_argument("--allow-dirty", action="store_true")
+ verify.add_argument(
+ "--phase",
+ choices=("build", "publish-ready", "signing-ready", "published"),
+ default="build",
+ )
+ verify.set_defaults(handler=command_verify)
+ consumer = subparsers.add_parser("consumer")
+ consumer.add_argument("--assets", required=True)
+ consumer.add_argument("--version", required=True)
+ consumer.set_defaults(handler=command_consumer)
+ consumer_config = subparsers.add_parser("consumer-config")
+ consumer_config.add_argument("--local-source", required=True)
+ consumer_config.add_argument("--output", required=True)
+ consumer_config.set_defaults(handler=command_consumer_config)
+ source_link = subparsers.add_parser("source-link")
+ source_link.add_argument("--file", required=True)
+ source_link.add_argument("--commit", required=True)
+ source_link.set_defaults(handler=command_source_link)
+ normalize = subparsers.add_parser("normalize-package")
+ normalize.add_argument("--package", required=True)
+ normalize.add_argument("--source-date-epoch", required=True)
+ normalize.add_argument("--version", required=True)
+ normalize.set_defaults(handler=command_normalize_package)
+ provenance = subparsers.add_parser("provenance")
+ provenance.add_argument("--root", required=True)
+ provenance.add_argument("--version", required=True)
+ provenance.add_argument("--commit", required=True)
+ provenance.add_argument("--tree-state", required=True)
+ provenance.add_argument("--source-date-epoch", required=True)
+ provenance.add_argument("--dotnet-sdk", required=True)
+ provenance.add_argument("--build-tool", action="append", default=[])
+ provenance.add_argument("--base-digest", action="append", default=[])
+ provenance.add_argument("--builder-base", action="append", default=[])
+ provenance.add_argument("--output", required=True)
+ provenance.set_defaults(handler=command_provenance)
+ record_container = subparsers.add_parser("record-container-build")
+ record_container.add_argument("--provenance", required=True)
+ record_container.add_argument("--buildx-version", required=True)
+ record_container.add_argument("--buildkit-version", required=True)
+ record_container.add_argument("--image-id", required=True)
+ record_container.set_defaults(handler=command_record_container_build)
+ record_digest = subparsers.add_parser("record-container-digest")
+ record_digest.add_argument("--release-dir", required=True)
+ record_digest.add_argument("--digest", required=True)
+ record_digest.set_defaults(handler=command_record_container_digest)
+ normalize_container_sbom = subparsers.add_parser("normalize-container-sbom")
+ normalize_container_sbom.add_argument("--file", required=True)
+ normalize_container_sbom.add_argument("--version", required=True)
+ normalize_container_sbom.add_argument("--commit", required=True)
+ normalize_container_sbom.add_argument("--source-date-epoch", required=True)
+ normalize_container_sbom.set_defaults(handler=command_normalize_container_sbom)
+ args = parser.parse_args()
+ args.handler(args)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/build-release.sh b/scripts/build-release.sh
new file mode 100755
index 0000000..27de802
--- /dev/null
+++ b/scripts/build-release.sh
@@ -0,0 +1,253 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+version="${1:-}"
+output="${2:-}"
+
+property() {
+ sed -n "s:.*<$1>\(.*\)$1>.*:\1:p" "$root/eng/Versions.props"
+}
+
+if [[ -z "$version" ]]; then
+ version="$(property RendezvousVersion)"
+fi
+semver='^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-([0-9A-Za-z-]+\.)*[0-9A-Za-z-]+)?(\+([0-9A-Za-z-]+\.)*[0-9A-Za-z-]+)?$'
+if [[ ! "$version" =~ $semver ]]; then
+ echo "Release version is not valid SemVer: $version" >&2
+ exit 1
+fi
+prerelease="${version%%+*}"
+if [[ "$prerelease" == *-* ]]; then
+ prerelease="${prerelease#*-}"
+ IFS='.' read -r -a prerelease_identifiers <<<"$prerelease"
+ for identifier in "${prerelease_identifiers[@]}"; do
+ if [[ "$identifier" =~ ^[0-9]+$ && ! "$identifier" =~ ^(0|[1-9][0-9]*)$ ]]; then
+ echo "Numeric prerelease identifiers must not contain leading zeroes: $version" >&2
+ exit 1
+ fi
+ done
+fi
+if [[ "$version" != "$(property RendezvousVersion)" ]]; then
+ echo "Requested version $version differs from eng/Versions.props." >&2
+ exit 1
+fi
+
+for command in dotnet git python3 tar gzip sha256sum cmp jq; do
+ command -v "$command" >/dev/null || {
+ echo "Required release command is unavailable: $command" >&2
+ exit 1
+ }
+done
+
+commit="$(git -C "$root" rev-parse HEAD)"
+source_date_epoch="$(git -C "$root" show -s --format=%ct "$commit")"
+tree_state=clean
+if [[ -n "$(git -C "$root" status --porcelain --untracked-files=normal)" ]]; then
+ tree_state=dirty
+fi
+allow_dirty=()
+if [[ "$tree_state" != clean ]]; then
+ if [[ "${RENDEZVOUS_RELEASE_ALLOW_DIRTY:-0}" != 1 ]]; then
+ echo "A formal release must be built from a clean Git tree." >&2
+ exit 1
+ fi
+ allow_dirty=(--allow-dirty)
+fi
+
+if [[ -z "$output" ]]; then
+ output="$root/artifacts/release/$version"
+fi
+if [[ -e "$output" ]]; then
+ echo "Release output already exists; refusing to overwrite: $output" >&2
+ exit 1
+fi
+mkdir -p "$(dirname "$output")"
+output="$(cd "$(dirname "$output")" && pwd)/$(basename "$output")"
+
+work="$(mktemp -d "${TMPDIR:-/tmp}/rendezvous-release.XXXXXX")"
+cleanup() {
+ rm -rf "$work"
+}
+trap cleanup EXIT
+mkdir -p "$work/pack-1" "$work/pack-2" "$work/publish-1" "$work/publish-2" "$output"
+
+create_server_archive() {
+ local publish_directory="$1"
+ local destination="$2"
+ tar --sort=name \
+ --mtime="@$source_date_epoch" \
+ --owner=0 --group=0 --numeric-owner \
+ -C "$publish_directory" -cf - . \
+ | gzip -n >"$destination"
+}
+
+common=(
+ -p:ContinuousIntegrationBuild=true
+ -p:PackageVersion="$version"
+ -p:RepositoryCommit="$commit"
+ -p:SourceRevisionId="$commit"
+)
+
+cd "$root"
+dotnet restore Rendezvous.slnx --locked-mode
+python3 eng/release_artifacts.py policy --root "$root" >"$work/dependency-policy.json"
+dotnet package list \
+ --project Rendezvous.slnx \
+ --vulnerable \
+ --include-transitive \
+ --no-restore \
+ --format json >"$work/nuget-vulnerabilities.json"
+python3 eng/release_artifacts.py audit --input "$work/nuget-vulnerabilities.json"
+dotnet format Rendezvous.slnx --verify-no-changes --no-restore
+api_before="$(sha256sum docs/api/*.json)"
+dotnet build Rendezvous.slnx --configuration Release --no-restore "${common[@]}"
+cp src/FinalFactory.Rendezvous.Server/bin/Release/net10.0/FinalFactory.Rendezvous.Server.dll \
+ "$work/FinalFactory.Rendezvous.Server.first.dll"
+cp src/FinalFactory.Rendezvous.Server/bin/Release/net10.0/FinalFactory.Rendezvous.Server.pdb \
+ "$work/FinalFactory.Rendezvous.Server.first.pdb"
+dotnet publish src/FinalFactory.Rendezvous.Server/FinalFactory.Rendezvous.Server.csproj \
+ --configuration Release \
+ --no-build \
+ --no-restore \
+ --output "$work/publish-1" \
+ -p:UseAppHost=false \
+ -p:OpenApiGenerateDocuments=false \
+ "${common[@]}"
+create_server_archive "$work/publish-1" "$work/FinalFactory.Rendezvous.Server.first.tar.gz"
+if [[ "$tree_state" == clean ]]; then
+ git diff --exit-code -- docs/api
+elif [[ "$api_before" != "$(sha256sum docs/api/*.json)" ]]; then
+ echo "Generated OpenAPI changed during the release build." >&2
+ exit 1
+fi
+dotnet test Rendezvous.slnx --configuration Release --no-build
+
+for project in Client Contracts; do
+ dotnet pack "src/FinalFactory.Rendezvous.$project/FinalFactory.Rendezvous.$project.csproj" \
+ --configuration Release --no-build --output "$work/pack-1" "${common[@]}"
+done
+for package in "$work/pack-1"/*; do
+ python3 eng/release_artifacts.py normalize-package \
+ --package "$package" \
+ --source-date-epoch "$source_date_epoch" \
+ --version "$version"
+done
+
+# Rebuild from the locked graph and prove package byte reproducibility.
+dotnet clean Rendezvous.slnx --configuration Release >/dev/null
+dotnet restore Rendezvous.slnx --locked-mode
+dotnet build Rendezvous.slnx --configuration Release --no-restore "${common[@]}"
+for project in Client Contracts; do
+ python3 eng/release_artifacts.py source-link \
+ --file "src/FinalFactory.Rendezvous.$project/obj/Release/netstandard2.1/FinalFactory.Rendezvous.$project.sourcelink.json" \
+ --commit "$commit"
+done
+cmp --silent \
+ "$work/FinalFactory.Rendezvous.Server.first.dll" \
+ src/FinalFactory.Rendezvous.Server/bin/Release/net10.0/FinalFactory.Rendezvous.Server.dll || {
+ echo "Server assembly is not byte reproducible." >&2
+ exit 1
+}
+cmp --silent \
+ "$work/FinalFactory.Rendezvous.Server.first.pdb" \
+ src/FinalFactory.Rendezvous.Server/bin/Release/net10.0/FinalFactory.Rendezvous.Server.pdb || {
+ echo "Server portable PDB is not byte reproducible." >&2
+ exit 1
+}
+for project in Client Contracts; do
+ dotnet pack "src/FinalFactory.Rendezvous.$project/FinalFactory.Rendezvous.$project.csproj" \
+ --configuration Release --no-build --output "$work/pack-2" "${common[@]}"
+done
+for package in "$work/pack-2"/*; do
+ python3 eng/release_artifacts.py normalize-package \
+ --package "$package" \
+ --source-date-epoch "$source_date_epoch" \
+ --version "$version"
+done
+for package in "$work/pack-1"/*; do
+ cmp --silent "$package" "$work/pack-2/$(basename "$package")" || {
+ echo "Package is not byte reproducible: $(basename "$package")" >&2
+ exit 1
+ }
+done
+cp "$work/pack-1"/* "$output/"
+
+python3 eng/release_artifacts.py consumer-config \
+ --local-source "$output" \
+ --output "$work/consumer.NuGet.config"
+for consumer in spacegame unscouted; do
+ project="$root/tests/consumers/$consumer/$(find "$root/tests/consumers/$consumer" -maxdepth 1 -name '*.csproj' -printf '%f\n')"
+ packages="$work/consumer-packages-$consumer"
+ dotnet restore "$project" \
+ -p:RendezvousPackageVersion="$version" \
+ -p:RestoreLockedMode=false \
+ --packages "$packages" \
+ --configfile "$work/consumer.NuGet.config" \
+ --force-evaluate
+ dotnet build "$project" \
+ --configuration Release --no-restore \
+ -p:RendezvousPackageVersion="$version"
+ assets="$(dirname "$project")/obj/project.assets.json"
+ python3 eng/release_artifacts.py consumer --assets "$assets" --version "$version"
+done
+
+dotnet publish src/FinalFactory.Rendezvous.Server/FinalFactory.Rendezvous.Server.csproj \
+ --configuration Release \
+ --no-build \
+ --no-restore \
+ --output "$work/publish-2" \
+ -p:UseAppHost=false \
+ -p:OpenApiGenerateDocuments=false \
+ "${common[@]}"
+server_archive="$output/FinalFactory.Rendezvous.Server.$version.linux-x64.tar.gz"
+create_server_archive "$work/publish-2" "$server_archive"
+cmp --silent "$work/FinalFactory.Rendezvous.Server.first.tar.gz" "$server_archive" || {
+ echo "Server archive is not byte reproducible." >&2
+ exit 1
+}
+
+cp CHANGELOG.md "$output/CHANGELOG.md"
+python3 eng/release_artifacts.py sbom \
+ --root "$root" \
+ --version "$version" \
+ --commit "$commit" \
+ --source-date-epoch "$source_date_epoch" \
+ --server-deps "$work/publish-2/FinalFactory.Rendezvous.Server.deps.json" \
+ --output "$output/FinalFactory.Rendezvous.$version.spdx.json"
+
+provenance=(
+ provenance
+ --root "$root"
+ --version "$version"
+ --commit "$commit"
+ --tree-state "$tree_state"
+ --source-date-epoch "$source_date_epoch"
+ --dotnet-sdk "$(dotnet --version)"
+ --build-tool "python=$(python3 --version 2>&1)"
+ --build-tool "tar=$(tar --version | sed -n '1p')"
+ --build-tool "gzip=$(gzip --version | sed -n '1p')"
+ --build-tool "jq=$(jq --version)"
+ --output "$output/release-provenance.json"
+)
+while IFS= read -r base; do
+ provenance+=(--base-digest "$base")
+done < <(sed -n 's/^FROM \([^ ]*\).*/\1/p' Dockerfile)
+while IFS= read -r base; do
+ provenance+=(--builder-base "$base")
+done < <(sed -n 's/^FROM \([^ ]*\).*/\1/p' eng/release-builder.Dockerfile)
+python3 eng/release_artifacts.py "${provenance[@]}"
+
+(
+ cd "$output"
+ find . -maxdepth 1 -type f ! -name checksums.sha256 -printf '%f\n' \
+ | LC_ALL=C sort \
+ | xargs sha256sum >checksums.sha256
+)
+python3 eng/release_artifacts.py verify \
+ --root "$root" \
+ --release-dir "$output" \
+ --version "$version" \
+ "${allow_dirty[@]}"
+
+echo "Release artifacts verified at $output"
diff --git a/scripts/check-compatibility.sh b/scripts/check-compatibility.sh
new file mode 100755
index 0000000..f7993da
--- /dev/null
+++ b/scripts/check-compatibility.sh
@@ -0,0 +1,56 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+base="${1:-origin/main}"
+
+if ! git -C "$root" cat-file -e "$base^{commit}" 2>/dev/null; then
+ echo "Compatibility base does not exist; this is valid only for the initial version baseline: $base"
+ exit 0
+fi
+if [[ "$(git -C "$root" rev-parse "$base")" == "$(git -C "$root" rev-parse HEAD)" ]]; then
+ base="HEAD^"
+fi
+if ! git -C "$root" cat-file -e "$base:eng/Versions.props" 2>/dev/null; then
+ echo "Base has no release version manifest; accepting the initial compatibility baseline."
+ exit 0
+fi
+
+current_property() {
+ sed -n "s:.*<$1>\(.*\)$1>.*:\1:p" "$root/eng/Versions.props"
+}
+base_property() {
+ git -C "$root" show "$base:eng/Versions.props" \
+ | sed -n "s:.*<$1>\(.*\)$1>.*:\1:p"
+}
+changed() {
+ git -C "$root" diff --name-only "$base"...HEAD -- "$@" | grep -q .
+}
+require_increase() {
+ local property="$1"
+ local description="$2"
+ shift 2
+ if changed "$@"; then
+ local before after
+ before="$(base_property "$property")"
+ after="$(current_property "$property")"
+ if [[ ! "$before" =~ ^[0-9]+$ || ! "$after" =~ ^[0-9]+$ || "$after" -le "$before" ]]; then
+ echo "$description changed without increasing $property ($before -> $after)." >&2
+ exit 1
+ fi
+ fi
+}
+
+require_increase RendezvousMajorVersion "Published .NET API snapshot" \
+ 'tests/FinalFactory.Rendezvous.Tests/TestData/Contracts/v*/client-public-api.txt' \
+ 'tests/FinalFactory.Rendezvous.Tests/TestData/Contracts/v*/contracts-public-api.txt'
+require_increase HttpContractVersion "HTTP/OpenAPI contract evidence" \
+ 'docs/api/*.json' \
+ 'tests/FinalFactory.Rendezvous.Tests/TestData/Contracts/v*/*.json' \
+ ':(exclude)tests/FinalFactory.Rendezvous.Tests/TestData/Contracts/v*/connection-ticket.json'
+require_increase UdpContractVersion "UDP contract evidence" \
+ 'tests/FinalFactory.Rendezvous.Tests/TestData/Contracts/v*/*.hex'
+require_increase ConnectionTicketFormatVersion "Connection-ticket format evidence" \
+ 'tests/FinalFactory.Rendezvous.Tests/TestData/Contracts/v*/connection-ticket.json'
+
+echo "Compatibility changes are paired with the required version increase."
diff --git a/scripts/check-release-tag.sh b/scripts/check-release-tag.sh
new file mode 100755
index 0000000..a02ac83
--- /dev/null
+++ b/scripts/check-release-tag.sh
@@ -0,0 +1,27 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+tag="${1:-${GITHUB_REF_NAME:-}}"
+version="$(sed -n 's:.*\(.*\).*:\1:p' "$root/eng/Versions.props")"
+
+if [[ "$tag" != "v$version" ]]; then
+ echo "Release tag $tag does not match central version v$version." >&2
+ exit 1
+fi
+if [[ -n "$(git -C "$root" status --porcelain --untracked-files=normal)" ]]; then
+ echo "Release tag checkout is not clean." >&2
+ exit 1
+fi
+if [[ "$(git -C "$root" tag --points-at HEAD --list "$tag")" != "$tag" ]]; then
+ echo "Release tag $tag does not point at the checked-out commit." >&2
+ exit 1
+fi
+if ! grep -Eq "^## $version - [0-9]{4}-[0-9]{2}-[0-9]{2}$" "$root/CHANGELOG.md"; then
+ echo "CHANGELOG.md must contain a dated heading for $version." >&2
+ exit 1
+fi
+if grep -Eq "^## $version - Unreleased$" "$root/CHANGELOG.md"; then
+ echo "Release $version is still marked Unreleased." >&2
+ exit 1
+fi
diff --git a/scripts/finalize-release-candidate.sh b/scripts/finalize-release-candidate.sh
new file mode 100755
index 0000000..7d8d077
--- /dev/null
+++ b/scripts/finalize-release-candidate.sh
@@ -0,0 +1,26 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+version="${1:?usage: finalize-release-candidate.sh VERSION RELEASE_DIRECTORY}"
+release_dir="${2:?usage: finalize-release-candidate.sh VERSION RELEASE_DIRECTORY}"
+container_sbom="$release_dir/FinalFactory.Rendezvous.Container.$version.spdx.json"
+
+[[ -s "$container_sbom" ]] || {
+ echo "Container SBOM is missing or empty: $container_sbom" >&2
+ exit 1
+}
+
+(
+ cd "$release_dir"
+ find . -maxdepth 1 -type f ! -name checksums.sha256 -printf '%f\n' \
+ | LC_ALL=C sort \
+ | xargs sha256sum >checksums.sha256
+)
+python3 "$root/eng/release_artifacts.py" verify \
+ --root "$root" \
+ --release-dir "$release_dir" \
+ --version "$version" \
+ --phase publish-ready
+
+echo "Finalized publish-ready release candidate $version"
diff --git a/scripts/finalize-signing-ready-release.sh b/scripts/finalize-signing-ready-release.sh
new file mode 100755
index 0000000..bace74c
--- /dev/null
+++ b/scripts/finalize-signing-ready-release.sh
@@ -0,0 +1,23 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+version="${1:?usage: finalize-signing-ready-release.sh VERSION RELEASE_DIRECTORY}"
+release_dir="${2:?usage: finalize-signing-ready-release.sh VERSION RELEASE_DIRECTORY}"
+
+(
+ cd "$release_dir"
+ find . -maxdepth 1 -type f \
+ ! -name checksums.sha256 \
+ ! -name checksums.sha256.bundle \
+ -printf '%f\n' \
+ | LC_ALL=C sort \
+ | xargs sha256sum >checksums.sha256
+)
+python3 "$root/eng/release_artifacts.py" verify \
+ --root "$root" \
+ --release-dir "$release_dir" \
+ --version "$version" \
+ --phase signing-ready
+
+echo "Finalized signing-ready release $version"
diff --git a/scripts/publish-release.sh b/scripts/publish-release.sh
new file mode 100755
index 0000000..ef1f204
--- /dev/null
+++ b/scripts/publish-release.sh
@@ -0,0 +1,153 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+version="${1:?usage: publish-release.sh VERSION RELEASE_DIRECTORY}"
+release_dir="${2:?usage: publish-release.sh VERSION RELEASE_DIRECTORY}"
+api="${RENDEZVOUS_GITEA_API:-https://git.finalfactory.de/api/v1}"
+registry="${RENDEZVOUS_CONTAINER_REGISTRY:-git.finalfactory.de}"
+image="$registry/heikyu/rendezvous:$version"
+token="${RENDEZVOUS_RELEASE_TOKEN:?RENDEZVOUS_RELEASE_TOKEN is required}"
+username="${RENDEZVOUS_RELEASE_USERNAME:?RENDEZVOUS_RELEASE_USERNAME is required}"
+release_builder="${RENDEZVOUS_RELEASE_BUILDER:?RENDEZVOUS_RELEASE_BUILDER is required}"
+docker_config="$(mktemp -d)"
+curl_config="$(mktemp)"
+release_request=""
+release_response=""
+cleanup() {
+ [[ -z "$release_request" ]] || rm -f "$release_request"
+ [[ -z "$release_response" ]] || rm -f "$release_response"
+ rm -f "$curl_config"
+ rm -rf "$docker_config"
+}
+trap cleanup EXIT
+chmod 0700 "$docker_config"
+chmod 0600 "$curl_config"
+printf 'header = "Authorization: token %s"\n' "$token" >"$curl_config"
+export DOCKER_CONFIG="$docker_config"
+
+for command in cosign curl docker dotnet jq; do
+ command -v "$command" >/dev/null || {
+ echo "Required publication command is unavailable: $command" >&2
+ exit 1
+ }
+done
+
+run_release_builder() {
+ docker run --rm \
+ --user "$(id -u):$(id -g)" \
+ --volume "$root:/source:ro" \
+ --volume "$release_dir:$release_dir" \
+ --workdir /source \
+ "$release_builder" "$@"
+}
+
+"$root/scripts/check-release-tag.sh" "v$version"
+"$root/scripts/verify-release.sh" "$version" "$release_dir" publish-ready
+
+require_absent() {
+ local description="$1"
+ local url="$2"
+ local status
+ status="$(curl --silent --show-error --output /dev/null --write-out '%{http_code}' \
+ --config "$curl_config" "$url")"
+ if [[ "$status" != 404 ]]; then
+ echo "$description must not exist before publication (HTTP $status)." >&2
+ exit 1
+ fi
+}
+
+# Gitea package versions are immutable. Require all destinations to be empty
+# before the first write so a tag can never become a silent partial rerun.
+require_absent "Client package $version" \
+ "$api/packages/HeiKyu/nuget/FinalFactory.Rendezvous.Client/$version"
+require_absent "Contracts package $version" \
+ "$api/packages/HeiKyu/nuget/FinalFactory.Rendezvous.Contracts/$version"
+require_absent "Container $version" \
+ "$api/packages/HeiKyu/container/rendezvous/$version"
+require_absent "Release v$version" \
+ "$api/repos/HeiKyu/Rendezvous/releases/tags/v$version"
+
+feed="https://git.finalfactory.de/api/packages/HeiKyu/nuget/index.json"
+for package in \
+ "$release_dir/FinalFactory.Rendezvous.Contracts.$version.nupkg" \
+ "$release_dir/FinalFactory.Rendezvous.Client.$version.nupkg"; do
+ dotnet nuget push "$package" \
+ --source "$feed" \
+ --api-key "$token" \
+ --timeout 300
+done
+
+printf '%s' "$token" | docker login "$registry" --username "$username" --password-stdin
+expected_image_id="$(jq -er '.containerImageId' "$release_dir/release-provenance.json")"
+current_image_id="$(docker image inspect --format '{{.Id}}' "$image")"
+if [[ "$current_image_id" != "$expected_image_id" ]]; then
+ echo "Local release tag changed after staging ($expected_image_id -> $current_image_id)." >&2
+ exit 1
+fi
+docker push "$image"
+digest_ref="$(docker inspect --format '{{index .RepoDigests 0}}' "$image")"
+if [[ ! "$digest_ref" =~ ^git\.finalfactory\.de/heikyu/rendezvous@sha256:[0-9a-f]{64}$ ]]; then
+ echo "Registry did not return an immutable Rendezvous image digest: $digest_ref" >&2
+ exit 1
+fi
+run_release_builder python3 eng/release_artifacts.py record-container-digest \
+ --release-dir "$release_dir" \
+ --digest "$digest_ref"
+cosign public-key --key env://COSIGN_PRIVATE_KEY >"$release_dir/cosign.pub"
+run_release_builder ./scripts/finalize-signing-ready-release.sh \
+ "$version" "$release_dir"
+
+cosign sign --yes --key env://COSIGN_PRIVATE_KEY "$digest_ref"
+cosign attest --yes \
+ --key env://COSIGN_PRIVATE_KEY \
+ --type https://finalfactory.de/rendezvous/release-provenance/v1 \
+ --predicate "$release_dir/release-provenance.json" \
+ "$digest_ref"
+cosign sign-blob --yes \
+ --key env://COSIGN_PRIVATE_KEY \
+ --bundle "$release_dir/checksums.sha256.bundle" \
+ "$release_dir/checksums.sha256"
+"$root/scripts/verify-release.sh" "$version" "$release_dir" published
+cosign verify --key "$release_dir/cosign.pub" "$digest_ref" >/dev/null
+cosign verify-attestation \
+ --key "$release_dir/cosign.pub" \
+ --type https://finalfactory.de/rendezvous/release-provenance/v1 \
+ "$digest_ref" >/dev/null
+cosign verify-blob \
+ --key "$release_dir/cosign.pub" \
+ --bundle "$release_dir/checksums.sha256.bundle" \
+ "$release_dir/checksums.sha256" >/dev/null
+
+release_request="$(mktemp)"
+release_response="$(mktemp)"
+prerelease=false
+if [[ "$version" == *-* ]]; then
+ prerelease=true
+fi
+jq -n \
+ --arg tag "v$version" \
+ --arg commit "${GITHUB_SHA:?GITHUB_SHA is required}" \
+ --arg digest "$digest_ref" \
+ --argjson prerelease "$prerelease" \
+ --rawfile changelog "$release_dir/CHANGELOG.md" \
+ '{tag_name:$tag,target_commitish:$commit,name:("Rendezvous " + $tag),body:($changelog + "\n\n## Immutable container\n\n`" + $digest + "`\n"),draft:false,prerelease:$prerelease}' \
+ >"$release_request"
+curl --fail --silent --show-error \
+ --request POST \
+ --config "$curl_config" \
+ --header 'Content-Type: application/json' \
+ --data-binary "@$release_request" \
+ "$api/repos/HeiKyu/Rendezvous/releases" >"$release_response"
+release_id="$(jq -er '.id' "$release_response")"
+
+for artifact in "$release_dir"/*; do
+ curl --fail --silent --show-error \
+ --request POST \
+ --config "$curl_config" \
+ --form "attachment=@$artifact" \
+ "$api/repos/HeiKyu/Rendezvous/releases/$release_id/assets?name=$(basename "$artifact")" \
+ >/dev/null
+done
+
+echo "Published immutable release v$version with container $digest_ref"
diff --git a/scripts/verify-real-consumers.sh b/scripts/verify-real-consumers.sh
new file mode 100755
index 0000000..1a843b6
--- /dev/null
+++ b/scripts/verify-real-consumers.sh
@@ -0,0 +1,78 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+version="${1:?usage: verify-real-consumers.sh VERSION RELEASE_DIRECTORY}"
+release_dir="${2:?usage: verify-real-consumers.sh VERSION RELEASE_DIRECTORY}"
+manifest="$root/eng/consumer-revisions.json"
+work="$(mktemp -d "${TMPDIR:-/tmp}/rendezvous-consumers.XXXXXX")"
+cleanup() {
+ rm -rf "$work"
+}
+trap cleanup EXIT
+
+for command in dotnet git jq python3; do
+ command -v "$command" >/dev/null || {
+ echo "Required consumer verification command is unavailable: $command" >&2
+ exit 1
+ }
+done
+
+python3 "$root/eng/release_artifacts.py" consumer-config \
+ --local-source "$release_dir" \
+ --output "$work/NuGet.config"
+
+count="$(jq '.consumers | length' "$manifest")"
+for ((index = 0; index < count; index++)); do
+ name="$(jq -r ".consumers[$index].name" "$manifest")"
+ repository="$(jq -r ".consumers[$index].repository" "$manifest")"
+ revision="$(jq -r ".consumers[$index].revision" "$manifest")"
+ project_relative="$(jq -r ".consumers[$index].project" "$manifest")"
+ checkout="$work/$name"
+ git -c init.defaultBranch=main init --quiet "$checkout"
+ git -C "$checkout" remote add origin "$repository"
+ git -C "$checkout" fetch --quiet --depth 1 origin "$revision"
+ GIT_LFS_SKIP_SMUDGE=1 git -C "$checkout" checkout --quiet --detach FETCH_HEAD
+ [[ "$(git -C "$checkout" rev-parse HEAD)" == "$revision" ]] || {
+ echo "$name did not resolve the pinned consumer revision." >&2
+ exit 1
+ }
+
+ project="$checkout/$project_relative"
+ [[ -f "$project" ]] || {
+ echo "$name consumer project does not exist at $project_relative." >&2
+ exit 1
+ }
+ targets="$work/$name.Rendezvous.Consumer.targets"
+ cat >"$targets" <
+
+
+
+
+
+EOF
+ packages="$work/packages-$name"
+ dotnet restore "$project" \
+ -p:CustomAfterMicrosoftCommonTargets="$targets" \
+ -p:RestorePackagesWithLockFile=false \
+ -p:RestoreLockedMode=false \
+ --packages "$packages" \
+ --configfile "$work/NuGet.config" \
+ --force-evaluate
+ assets=""
+ while IFS= read -r candidate_assets; do
+ if grep -Fq "FinalFactory.Rendezvous.Client/$version" "$candidate_assets"; then
+ assets="$candidate_assets"
+ break
+ fi
+ done < <(find "$checkout" -path '*/obj/project.assets.json' -type f -print)
+ [[ -n "$assets" ]] || {
+ echo "$name restore did not produce assets for the injected Rendezvous references." >&2
+ exit 1
+ }
+ python3 "$root/eng/release_artifacts.py" consumer \
+ --assets "$assets" \
+ --version "$version"
+ echo "Verified $name at $revision can pin and restore Rendezvous $version."
+done
diff --git a/scripts/verify-release.sh b/scripts/verify-release.sh
new file mode 100755
index 0000000..83fb21a
--- /dev/null
+++ b/scripts/verify-release.sh
@@ -0,0 +1,13 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+version="${1:?usage: verify-release.sh VERSION [RELEASE_DIRECTORY] [PHASE]}"
+release_dir="${2:-$root/artifacts/release/$version}"
+phase="${3:-build}"
+
+python3 "$root/eng/release_artifacts.py" verify \
+ --root "$root" \
+ --release-dir "$release_dir" \
+ --version "$version" \
+ --phase "$phase"
diff --git a/src/FinalFactory.Rendezvous.Client/FinalFactory.Rendezvous.Client.csproj b/src/FinalFactory.Rendezvous.Client/FinalFactory.Rendezvous.Client.csproj
index f18e326..4231052 100644
--- a/src/FinalFactory.Rendezvous.Client/FinalFactory.Rendezvous.Client.csproj
+++ b/src/FinalFactory.Rendezvous.Client/FinalFactory.Rendezvous.Client.csproj
@@ -7,10 +7,12 @@
FinalFactory.Rendezvous.Client
README.md
Godot-independent client SDK for Final Factory Rendezvous.
+ final-factory;multiplayer;nat;godot;litenetlib
+
diff --git a/src/FinalFactory.Rendezvous.Client/packages.lock.json b/src/FinalFactory.Rendezvous.Client/packages.lock.json
index 3a3a4c1..953a75c 100644
--- a/src/FinalFactory.Rendezvous.Client/packages.lock.json
+++ b/src/FinalFactory.Rendezvous.Client/packages.lock.json
@@ -4,7 +4,7 @@
".NETStandard,Version=v2.1": {
"LiteNetLib": {
"type": "Direct",
- "requested": "[2.1.4, )",
+ "requested": "[2.1.4, 2.1.4]",
"resolved": "2.1.4",
"contentHash": "KWlxvMw3Urpqj9joD96LRiK+LC62pQNs/zkXRJc+rHnxgkGp+vV703xzDrxRmv+V1YhCFfIGzs5nrVWtREIlyA=="
},
diff --git a/src/FinalFactory.Rendezvous.Contracts/FinalFactory.Rendezvous.Contracts.csproj b/src/FinalFactory.Rendezvous.Contracts/FinalFactory.Rendezvous.Contracts.csproj
index 8eefa3d..fe50516 100644
--- a/src/FinalFactory.Rendezvous.Contracts/FinalFactory.Rendezvous.Contracts.csproj
+++ b/src/FinalFactory.Rendezvous.Contracts/FinalFactory.Rendezvous.Contracts.csproj
@@ -5,9 +5,13 @@
FinalFactory.Rendezvous.Contracts
true
FinalFactory.Rendezvous.Contracts
+ README.md
Versioned transport-neutral contracts for Final Factory Rendezvous.
+ final-factory;multiplayer;contracts;godot
+
+
diff --git a/src/FinalFactory.Rendezvous.Contracts/README.md b/src/FinalFactory.Rendezvous.Contracts/README.md
new file mode 100644
index 0000000..baefa36
--- /dev/null
+++ b/src/FinalFactory.Rendezvous.Contracts/README.md
@@ -0,0 +1,8 @@
+# FinalFactory.Rendezvous.Contracts
+
+Transport-neutral v1 HTTP/UDP contract types for Final Factory Rendezvous.
+The package targets `netstandard2.1`, contains no Godot or LiteNetLib dependency,
+and is versioned with the Client package and server release.
+
+Compatibility and migration policy is maintained in the repository's
+`docs/releases/README.md` document.
diff --git a/src/FinalFactory.Rendezvous.Server/Deployment/GracefulDrainService.cs b/src/FinalFactory.Rendezvous.Server/Deployment/GracefulDrainService.cs
index 004e1c0..477108e 100644
--- a/src/FinalFactory.Rendezvous.Server/Deployment/GracefulDrainService.cs
+++ b/src/FinalFactory.Rendezvous.Server/Deployment/GracefulDrainService.cs
@@ -4,7 +4,7 @@ using Microsoft.Extensions.Options;
namespace FinalFactory.Rendezvous.Server.Deployment;
-internal sealed partial class GracefulDrainService : IHostedService, IDisposable
+internal sealed class GracefulDrainService : IHostedService, IDisposable
{
private static readonly TimeSpan PollInterval = TimeSpan.FromMilliseconds(50);
private readonly InMemoryEphemeralRendezvousStore _store;
@@ -84,15 +84,19 @@ internal sealed partial class GracefulDrainService : IHostedService, IDisposable
}
}
- [LoggerMessage(
- EventId = 1,
- Level = LogLevel.Information,
- Message = "Graceful drain started with a {DrainDeadlineSeconds}-second deadline")]
- private static partial void LogDrainStarted(ILogger logger, int drainDeadlineSeconds);
+ private static readonly Action DrainStarted = LoggerMessage.Define(
+ LogLevel.Information,
+ new EventId(1, nameof(LogDrainStarted)),
+ "Graceful drain started with a {DrainDeadlineSeconds}-second deadline");
- [LoggerMessage(
- EventId = 2,
- Level = LogLevel.Information,
- Message = "Graceful drain finished after {ElapsedMilliseconds:F0} ms; ephemeral state was cleared")]
- private static partial void LogDrainFinished(ILogger logger, double elapsedMilliseconds);
+ private static readonly Action DrainFinished = LoggerMessage.Define(
+ LogLevel.Information,
+ new EventId(2, nameof(LogDrainFinished)),
+ "Graceful drain finished after {ElapsedMilliseconds:F0} ms; ephemeral state was cleared");
+
+ private static void LogDrainStarted(ILogger logger, int drainDeadlineSeconds) =>
+ DrainStarted(logger, drainDeadlineSeconds, null);
+
+ private static void LogDrainFinished(ILogger logger, double elapsedMilliseconds) =>
+ DrainFinished(logger, elapsedMilliseconds, null);
}
diff --git a/src/FinalFactory.Rendezvous.Server/FinalFactory.Rendezvous.Server.csproj b/src/FinalFactory.Rendezvous.Server/FinalFactory.Rendezvous.Server.csproj
index 84b8560..918f605 100644
--- a/src/FinalFactory.Rendezvous.Server/FinalFactory.Rendezvous.Server.csproj
+++ b/src/FinalFactory.Rendezvous.Server/FinalFactory.Rendezvous.Server.csproj
@@ -3,6 +3,7 @@
net10.0
FinalFactory.Rendezvous.Server
FinalFactory.Rendezvous.Server
+ false
false
true
$(MSBuildProjectDirectory)/../../docs/api
@@ -14,4 +15,74 @@
+
+
+ <_Parameter1>RendezvousMinimumClientVersion
+ <_Parameter2>$(MinimumClientVersion)
+
+
+ <_Parameter1>RendezvousMaximumClientMajorVersion
+ <_Parameter2>$(MaximumClientMajorVersion)
+
+
+ <_Parameter1>RendezvousUdpContractVersion
+ <_Parameter2>$(UdpContractVersion)
+
+
+ <_Parameter1>RendezvousConnectionTicketFormatVersion
+ <_Parameter2>$(ConnectionTicketFormatVersion)
+
+
+ <_Parameter1>RendezvousLiteNetLibMajorVersion
+ <_Parameter2>$(LiteNetLibMajorVersion)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/FinalFactory.Rendezvous.Server/Http/RendezvousExceptionHandler.cs b/src/FinalFactory.Rendezvous.Server/Http/RendezvousExceptionHandler.cs
index 82b0a5e..d6d2518 100644
--- a/src/FinalFactory.Rendezvous.Server/Http/RendezvousExceptionHandler.cs
+++ b/src/FinalFactory.Rendezvous.Server/Http/RendezvousExceptionHandler.cs
@@ -4,7 +4,7 @@ using Microsoft.AspNetCore.Diagnostics;
namespace FinalFactory.Rendezvous.Server.Http;
-internal sealed partial class RendezvousExceptionHandler(
+internal sealed class RendezvousExceptionHandler(
ILogger logger) : IExceptionHandler
{
public async ValueTask TryHandleAsync(
@@ -51,13 +51,15 @@ internal sealed partial class RendezvousExceptionHandler(
return true;
}
- [LoggerMessage(
- EventId = 200,
- Level = LogLevel.Warning,
- Message = "Request failed with {FailureKind} and HTTP status {StatusCode}; correlation {CorrelationId}")]
- private static partial void LogRequestFailure(
+ private static readonly Action RequestFailure =
+ LoggerMessage.Define(
+ LogLevel.Warning,
+ new EventId(200, nameof(LogRequestFailure)),
+ "Request failed with {FailureKind} and HTTP status {StatusCode}; correlation {CorrelationId}");
+
+ private static void LogRequestFailure(
ILogger logger,
string failureKind,
int statusCode,
- string correlationId);
+ string correlationId) => RequestFailure(logger, failureKind, statusCode, correlationId, null);
}
diff --git a/src/FinalFactory.Rendezvous.Server/Observability/AuditTrail.cs b/src/FinalFactory.Rendezvous.Server/Observability/AuditTrail.cs
index e4f0501..017a3fb 100644
--- a/src/FinalFactory.Rendezvous.Server/Observability/AuditTrail.cs
+++ b/src/FinalFactory.Rendezvous.Server/Observability/AuditTrail.cs
@@ -4,7 +4,7 @@ using Microsoft.Extensions.Options;
namespace FinalFactory.Rendezvous.Server.Observability;
-internal sealed partial class AuditTrail
+internal sealed class AuditTrail
{
private readonly object _gate = new();
private readonly LinkedList _entries = [];
@@ -57,7 +57,6 @@ internal sealed partial class AuditTrail
_telemetry.RecordAudit(action, result);
LogOperatorAction(
_logger,
- entry.Timestamp,
entry.ActorFingerprint,
action,
result,
@@ -105,19 +104,28 @@ internal sealed partial class AuditTrail
return Convert.ToHexString(digest.AsSpan(0, 12));
}
- [LoggerMessage(
- EventId = 100,
- Level = LogLevel.Information,
- Message = "Operator audit at {Timestamp}: actor {ActorFingerprint} action {Action} completed with {Result} for {TargetKind} target {TargetFingerprint}; correlation {CorrelationId}")]
- private static partial void LogOperatorAction(
+ private static readonly Action
+ OperatorAction = LoggerMessage.Define(
+ LogLevel.Information,
+ new EventId(100, nameof(LogOperatorAction)),
+ "Operator audit: actor {ActorFingerprint} action {Action} completed with {Result} for {TargetKind} target {TargetFingerprint}; correlation {CorrelationId}");
+
+ private static void LogOperatorAction(
ILogger logger,
- DateTimeOffset timestamp,
string actorFingerprint,
string action,
string result,
string targetKind,
string targetFingerprint,
- string correlationId);
+ string correlationId) => OperatorAction(
+ logger,
+ actorFingerprint,
+ action,
+ result,
+ targetKind,
+ targetFingerprint,
+ correlationId,
+ null);
}
internal sealed record AuditEntry(
diff --git a/src/FinalFactory.Rendezvous.Server/Operations/OperatorModels.cs b/src/FinalFactory.Rendezvous.Server/Operations/OperatorModels.cs
index 4aa2101..7fadf08 100644
--- a/src/FinalFactory.Rendezvous.Server/Operations/OperatorModels.cs
+++ b/src/FinalFactory.Rendezvous.Server/Operations/OperatorModels.cs
@@ -3,6 +3,7 @@ namespace FinalFactory.Rendezvous.Server.Operations;
internal sealed record OperatorStatusResponse
{
public required string Status { get; init; }
+ public required OperatorCompatibilityResponse Compatibility { get; init; }
public required OperatorReadinessResponse Readiness { get; init; }
public required OperatorStoreResponse Store { get; init; }
public required IReadOnlyList Tenants { get; init; }
@@ -10,6 +11,18 @@ internal sealed record OperatorStatusResponse
public required IReadOnlyDictionary AuditCounts { get; init; }
}
+internal sealed record OperatorCompatibilityResponse
+{
+ public required string ServerVersion { get; init; }
+ public required string MinimumClientVersion { get; init; }
+ public required int MaximumClientMajorVersion { get; init; }
+ public required IReadOnlyList HttpContractVersions { get; init; }
+ public required IReadOnlyList UdpContractVersions { get; init; }
+ public required IReadOnlyList ConnectionTicketFormatVersions { get; init; }
+ public required int LiteNetLibMajorVersion { get; init; }
+ public required string GameplayProtocolCompatibility { get; init; }
+}
+
internal sealed record OperatorReadinessResponse
{
public required bool HttpListener { get; init; }
diff --git a/src/FinalFactory.Rendezvous.Server/Operations/OperatorService.cs b/src/FinalFactory.Rendezvous.Server/Operations/OperatorService.cs
index 1447ba3..48f7d2b 100644
--- a/src/FinalFactory.Rendezvous.Server/Operations/OperatorService.cs
+++ b/src/FinalFactory.Rendezvous.Server/Operations/OperatorService.cs
@@ -19,6 +19,7 @@ internal sealed class OperatorService(
return new OperatorStatusResponse
{
Status = readinessSnapshot.IsReady ? "ready" : "not-ready",
+ Compatibility = ReleaseCompatibility.CreateResponse(),
Readiness = new OperatorReadinessResponse
{
HttpListener = readinessSnapshot.HttpListenerReady,
diff --git a/src/FinalFactory.Rendezvous.Server/Operations/ReleaseCompatibility.cs b/src/FinalFactory.Rendezvous.Server/Operations/ReleaseCompatibility.cs
new file mode 100644
index 0000000..06dcca0
--- /dev/null
+++ b/src/FinalFactory.Rendezvous.Server/Operations/ReleaseCompatibility.cs
@@ -0,0 +1,41 @@
+using System.Reflection;
+using FinalFactory.Rendezvous.Contracts;
+
+namespace FinalFactory.Rendezvous.Server.Operations;
+
+internal static class ReleaseCompatibility
+{
+ private static readonly Assembly ServerAssembly = typeof(ReleaseCompatibility).Assembly;
+
+ internal static string MinimumClientVersion => Metadata("RendezvousMinimumClientVersion");
+ internal static int MaximumClientMajorVersion => MetadataInteger("RendezvousMaximumClientMajorVersion");
+ internal static int UdpContractVersion => MetadataInteger("RendezvousUdpContractVersion");
+ internal static int ConnectionTicketFormatVersion => MetadataInteger("RendezvousConnectionTicketFormatVersion");
+ internal static int LiteNetLibMajorVersion => MetadataInteger("RendezvousLiteNetLibMajorVersion");
+
+ internal static OperatorCompatibilityResponse CreateResponse() => new()
+ {
+ ServerVersion = ServerAssembly
+ .GetCustomAttribute()?
+ .InformationalVersion.Split('+', 2)[0]
+ ?? ServerAssembly.GetName().Version?.ToString(3)
+ ?? "unknown",
+ MinimumClientVersion = MinimumClientVersion,
+ MaximumClientMajorVersion = MaximumClientMajorVersion,
+ HttpContractVersions = [ContractLimits.ContractVersion],
+ UdpContractVersions = [UdpContractVersion],
+ ConnectionTicketFormatVersions = [ConnectionTicketFormatVersion],
+ LiteNetLibMajorVersion = LiteNetLibMajorVersion,
+ GameplayProtocolCompatibility = "exact-per-tenant",
+ };
+
+ private static string Metadata(string key) => ServerAssembly
+ .GetCustomAttributes()
+ .Single(attribute => string.Equals(attribute.Key, key, StringComparison.Ordinal))
+ .Value
+ ?? throw new InvalidOperationException($"Assembly metadata {key} has no value.");
+
+ private static int MetadataInteger(string key) => int.Parse(
+ Metadata(key),
+ System.Globalization.CultureInfo.InvariantCulture);
+}
diff --git a/src/FinalFactory.Rendezvous.Server/Transport/UdpMediatorService.cs b/src/FinalFactory.Rendezvous.Server/Transport/UdpMediatorService.cs
index 3a60163..2166b85 100644
--- a/src/FinalFactory.Rendezvous.Server/Transport/UdpMediatorService.cs
+++ b/src/FinalFactory.Rendezvous.Server/Transport/UdpMediatorService.cs
@@ -8,7 +8,7 @@ using Microsoft.Extensions.Options;
namespace FinalFactory.Rendezvous.Server.Transport;
-internal sealed partial class UdpMediatorService : BackgroundService
+internal sealed class UdpMediatorService : BackgroundService
{
private readonly ILogger _logger;
private readonly UdpMediatorOptions _options;
@@ -133,20 +133,23 @@ internal sealed partial class UdpMediatorService : BackgroundService
manager?.Stop();
}
- [LoggerMessage(
- EventId = 1,
- Level = LogLevel.Information,
- Message = "UDP mediator listening on {ListenAddress}:{ListenPort}")]
- private static partial void LogMediatorListening(
+ private static readonly Action MediatorListening =
+ LoggerMessage.Define(
+ LogLevel.Information,
+ new EventId(1, nameof(LogMediatorListening)),
+ "UDP mediator listening on {ListenAddress}:{ListenPort}");
+
+ private static readonly Action MediatorStopped = LoggerMessage.Define(
+ LogLevel.Information,
+ new EventId(2, nameof(LogMediatorStopped)),
+ "UDP mediator stopped");
+
+ private static void LogMediatorListening(
ILogger logger,
IPAddress listenAddress,
- int listenPort);
+ int listenPort) => MediatorListening(logger, listenAddress, listenPort, null);
- [LoggerMessage(
- EventId = 2,
- Level = LogLevel.Information,
- Message = "UDP mediator stopped")]
- private static partial void LogMediatorStopped(ILogger logger);
+ private static void LogMediatorStopped(ILogger logger) => MediatorStopped(logger, null);
private sealed class LiteNetIntroductionSink(NatPunchModule module) : INatIntroductionSink
{
diff --git a/src/FinalFactory.Rendezvous.Server/packages.lock.json b/src/FinalFactory.Rendezvous.Server/packages.lock.json
index 17d9780..e48ee5c 100644
--- a/src/FinalFactory.Rendezvous.Server/packages.lock.json
+++ b/src/FinalFactory.Rendezvous.Server/packages.lock.json
@@ -4,7 +4,7 @@
"net10.0": {
"LiteNetLib": {
"type": "Direct",
- "requested": "[2.1.4, )",
+ "requested": "[2.1.4, 2.1.4]",
"resolved": "2.1.4",
"contentHash": "KWlxvMw3Urpqj9joD96LRiK+LC62pQNs/zkXRJc+rHnxgkGp+vV703xzDrxRmv+V1YhCFfIGzs5nrVWtREIlyA=="
},
diff --git a/src/FinalFactory.Rendezvous.TestClient/packages.lock.json b/src/FinalFactory.Rendezvous.TestClient/packages.lock.json
index f3e75b5..1cc635b 100644
--- a/src/FinalFactory.Rendezvous.TestClient/packages.lock.json
+++ b/src/FinalFactory.Rendezvous.TestClient/packages.lock.json
@@ -4,7 +4,7 @@
"net8.0": {
"LiteNetLib": {
"type": "Direct",
- "requested": "[2.1.4, )",
+ "requested": "[2.1.4, 2.1.4]",
"resolved": "2.1.4",
"contentHash": "KWlxvMw3Urpqj9joD96LRiK+LC62pQNs/zkXRJc+rHnxgkGp+vV703xzDrxRmv+V1YhCFfIGzs5nrVWtREIlyA=="
},
@@ -22,7 +22,7 @@
"type": "Project",
"dependencies": {
"FinalFactory.Rendezvous.Contracts": "[1.0.0, )",
- "LiteNetLib": "[2.1.4, )"
+ "LiteNetLib": "[2.1.4, 2.1.4]"
}
},
"finalfactory.rendezvous.contracts": {
diff --git a/tests/FinalFactory.Rendezvous.Capacity/packages.lock.json b/tests/FinalFactory.Rendezvous.Capacity/packages.lock.json
index 14ff028..029e1fe 100644
--- a/tests/FinalFactory.Rendezvous.Capacity/packages.lock.json
+++ b/tests/FinalFactory.Rendezvous.Capacity/packages.lock.json
@@ -9,13 +9,13 @@
"type": "Project",
"dependencies": {
"FinalFactory.Rendezvous.Contracts": "[1.0.0, )",
- "LiteNetLib": "[2.1.4, )",
+ "LiteNetLib": "[2.1.4, 2.1.4]",
"Microsoft.AspNetCore.OpenApi": "[10.0.9, )"
}
},
"LiteNetLib": {
"type": "CentralTransitive",
- "requested": "[2.1.4, )",
+ "requested": "[2.1.4, 2.1.4]",
"resolved": "2.1.4",
"contentHash": "KWlxvMw3Urpqj9joD96LRiK+LC62pQNs/zkXRJc+rHnxgkGp+vV703xzDrxRmv+V1YhCFfIGzs5nrVWtREIlyA=="
},
diff --git a/tests/FinalFactory.Rendezvous.Tests/Contracts/ContractTestFiles.cs b/tests/FinalFactory.Rendezvous.Tests/Contracts/ContractTestFiles.cs
index 198f6e7..19634c3 100644
--- a/tests/FinalFactory.Rendezvous.Tests/Contracts/ContractTestFiles.cs
+++ b/tests/FinalFactory.Rendezvous.Tests/Contracts/ContractTestFiles.cs
@@ -1,30 +1,69 @@
+using System.Xml.Linq;
+
namespace FinalFactory.Rendezvous.Tests.Contracts;
internal static class ContractTestFiles
{
public static string Read(string fileName) => File
- .ReadAllText(Path.Combine(Directory, fileName))
+ .ReadAllText(Path.Combine(DirectoryFor(fileName), fileName))
.TrimEnd('\r', '\n');
public static string Directory
+ {
+ get
+ {
+ return VersionedDirectory(Property("RendezvousMajorVersion"));
+ }
+ }
+
+ public static string OpenApiDocument
+ {
+ get
+ {
+ string httpVersion = Property("HttpContractVersion");
+ return Path.Combine(RepositoryRoot, $"docs/api/rendezvous-v{httpVersion}.json");
+ }
+ }
+
+ private static string DirectoryFor(string fileName)
+ {
+ string property = fileName switch
+ {
+ "client-public-api.txt" or "contracts-public-api.txt" => "RendezvousMajorVersion",
+ "connection-ticket.json" => "ConnectionTicketFormatVersion",
+ _ when fileName.EndsWith(".hex", StringComparison.Ordinal) => "UdpContractVersion",
+ _ when fileName.EndsWith(".json", StringComparison.Ordinal) => "HttpContractVersion",
+ _ => throw new InvalidOperationException($"No contract version dimension maps {fileName}."),
+ };
+ return VersionedDirectory(Property(property));
+ }
+
+ private static string VersionedDirectory(string version) => Path.Combine(
+ RepositoryRoot,
+ $"tests/FinalFactory.Rendezvous.Tests/TestData/Contracts/v{version}");
+
+ private static string Property(string name)
+ {
+ XDocument versions = XDocument.Load(Path.Combine(RepositoryRoot, "eng/Versions.props"));
+ return versions.Descendants(name).Single().Value;
+ }
+
+ private static string RepositoryRoot
{
get
{
DirectoryInfo? directory = new(AppContext.BaseDirectory);
while (directory is not null)
{
- string solution = Path.Combine(directory.FullName, "Rendezvous.slnx");
- if (File.Exists(solution))
+ if (File.Exists(Path.Combine(directory.FullName, "Rendezvous.slnx")))
{
- return Path.Combine(
- directory.FullName,
- "tests/FinalFactory.Rendezvous.Tests/TestData/Contracts/v1");
+ return directory.FullName;
}
directory = directory.Parent;
}
- throw new DirectoryNotFoundException("Could not locate contract test data.");
+ throw new DirectoryNotFoundException("Could not locate repository root.");
}
}
}
diff --git a/tests/FinalFactory.Rendezvous.Tests/Contracts/OpenApiCompatibilityTests.cs b/tests/FinalFactory.Rendezvous.Tests/Contracts/OpenApiCompatibilityTests.cs
index 58f6235..787a565 100644
--- a/tests/FinalFactory.Rendezvous.Tests/Contracts/OpenApiCompatibilityTests.cs
+++ b/tests/FinalFactory.Rendezvous.Tests/Contracts/OpenApiCompatibilityTests.cs
@@ -40,12 +40,10 @@ public sealed class OpenApiCompatibilityTests
];
[Fact]
- public void GeneratedOpenApiContainsTheFrozenV1Surface()
+ public void GeneratedOpenApiContainsTheFrozenVersionedSurface()
{
- string path = Path.Combine(
- ContractTestFiles.Directory,
- "../../../../../docs/api/rendezvous-v1.json");
- using JsonDocument document = JsonDocument.Parse(File.ReadAllText(Path.GetFullPath(path)));
+ using JsonDocument document = JsonDocument.Parse(
+ File.ReadAllText(ContractTestFiles.OpenApiDocument));
JsonElement root = document.RootElement;
Assert.Equal("3.1.1", root.GetProperty("openapi").GetString());
diff --git a/tests/FinalFactory.Rendezvous.Tests/Contracts/TraversalTokenCodecTests.cs b/tests/FinalFactory.Rendezvous.Tests/Contracts/TraversalTokenCodecTests.cs
index 4591987..9fb8c78 100644
--- a/tests/FinalFactory.Rendezvous.Tests/Contracts/TraversalTokenCodecTests.cs
+++ b/tests/FinalFactory.Rendezvous.Tests/Contracts/TraversalTokenCodecTests.cs
@@ -1,3 +1,4 @@
+using System.Text.Json;
using FinalFactory.Rendezvous.Client;
using FinalFactory.Rendezvous.Contracts;
@@ -22,6 +23,20 @@ public sealed class TraversalTokenCodecTests
Assert.DoesNotContain(encoded, decoded.ToString(), StringComparison.Ordinal);
}
+ [Fact]
+ public void ConnectionTicketMatchesTheVersionedV1Vector()
+ {
+ using JsonDocument vector = JsonDocument.Parse(ContractTestFiles.Read("connection-ticket.json"));
+ JsonElement root = vector.RootElement;
+ JoinAttemptId attemptId = new(Guid.Parse(root.GetProperty("attemptId").GetString()!));
+ string authenticator = root.GetProperty("derivedAuthenticator").GetString()!;
+
+ string ticket = NatIntroductionTokenCodec.Encode(attemptId, authenticator);
+
+ Assert.Equal(root.GetProperty("connectionTicket").GetString(), ticket);
+ Assert.Equal(root.GetProperty("digest").GetString(), NatIntroductionTokenCodec.ComputeDigest(ticket));
+ }
+
[Fact]
public void IntroductionTokenRejectsNonCanonicalOrAlteredFields()
{
diff --git a/tests/FinalFactory.Rendezvous.Tests/Operations/OperatorEndpointTests.cs b/tests/FinalFactory.Rendezvous.Tests/Operations/OperatorEndpointTests.cs
index 1231aaf..5e96063 100644
--- a/tests/FinalFactory.Rendezvous.Tests/Operations/OperatorEndpointTests.cs
+++ b/tests/FinalFactory.Rendezvous.Tests/Operations/OperatorEndpointTests.cs
@@ -101,6 +101,16 @@ public sealed class OperatorEndpointTests
tenant.GameId == "space-game"
&& tenant.EnvironmentId == "production"
&& tenant.Status == "enabled");
+ Assert.Equal("1.0.0", operatorStatus.Compatibility.ServerVersion);
+ Assert.Equal("1.0.0", operatorStatus.Compatibility.MinimumClientVersion);
+ Assert.Equal(1, operatorStatus.Compatibility.MaximumClientMajorVersion);
+ Assert.Equal([1], operatorStatus.Compatibility.HttpContractVersions);
+ Assert.Equal([1], operatorStatus.Compatibility.UdpContractVersions);
+ Assert.Equal([1], operatorStatus.Compatibility.ConnectionTicketFormatVersions);
+ Assert.Equal(2, operatorStatus.Compatibility.LiteNetLibMajorVersion);
+ Assert.Equal(
+ "exact-per-tenant",
+ operatorStatus.Compatibility.GameplayProtocolCompatibility);
Assert.Contains(operatorStatus.SigningKeys, static key =>
key.KeyId == OperatorTestHost.OperatorKeyId
&& key.Status == "signing"
diff --git a/tests/FinalFactory.Rendezvous.Tests/Release/ReleaseCompatibilityTests.cs b/tests/FinalFactory.Rendezvous.Tests/Release/ReleaseCompatibilityTests.cs
new file mode 100644
index 0000000..c9f5424
--- /dev/null
+++ b/tests/FinalFactory.Rendezvous.Tests/Release/ReleaseCompatibilityTests.cs
@@ -0,0 +1,185 @@
+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()!.InformationalVersion.Split('+')[0]);
+ Assert.Equal(releaseVersion, typeof(ContractLimits).Assembly.GetCustomAttribute()!.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.Contains("ignore-unfixed: false", workflow, StringComparison.Ordinal);
+ Assert.Contains("format: 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()));
+ }
+
+ [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);
+ }
+
+ 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.");
+ }
+}
diff --git a/tests/FinalFactory.Rendezvous.Tests/TestData/Contracts/v1/connection-ticket.json b/tests/FinalFactory.Rendezvous.Tests/TestData/Contracts/v1/connection-ticket.json
new file mode 100644
index 0000000..fdd32af
--- /dev/null
+++ b/tests/FinalFactory.Rendezvous.Tests/TestData/Contracts/v1/connection-ticket.json
@@ -0,0 +1,6 @@
+{
+ "attemptId": "00000000-0000-0000-0000-000000000301",
+ "derivedAuthenticator": "TTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTT",
+ "connectionTicket": "AAAAAAAAAAAAAAAAAAADAU000000000000000000000",
+ "digest": "d6yCrbIOzwKoaOZQ8A9eggclDY0yzmhJH36FDz4L7wE"
+}
diff --git a/tests/FinalFactory.Rendezvous.Tests/packages.lock.json b/tests/FinalFactory.Rendezvous.Tests/packages.lock.json
index be9432e..ac339b2 100644
--- a/tests/FinalFactory.Rendezvous.Tests/packages.lock.json
+++ b/tests/FinalFactory.Rendezvous.Tests/packages.lock.json
@@ -97,7 +97,7 @@
"type": "Project",
"dependencies": {
"FinalFactory.Rendezvous.Contracts": "[1.0.0, )",
- "LiteNetLib": "[2.1.4, )"
+ "LiteNetLib": "[2.1.4, 2.1.4]"
}
},
"finalfactory.rendezvous.contracts": {
@@ -107,7 +107,7 @@
"type": "Project",
"dependencies": {
"FinalFactory.Rendezvous.Contracts": "[1.0.0, )",
- "LiteNetLib": "[2.1.4, )",
+ "LiteNetLib": "[2.1.4, 2.1.4]",
"Microsoft.AspNetCore.OpenApi": "[10.0.9, )"
}
},
@@ -116,12 +116,12 @@
"dependencies": {
"FinalFactory.Rendezvous.Client": "[1.0.0, )",
"FinalFactory.Rendezvous.Contracts": "[1.0.0, )",
- "LiteNetLib": "[2.1.4, )"
+ "LiteNetLib": "[2.1.4, 2.1.4]"
}
},
"LiteNetLib": {
"type": "CentralTransitive",
- "requested": "[2.1.4, )",
+ "requested": "[2.1.4, 2.1.4]",
"resolved": "2.1.4",
"contentHash": "KWlxvMw3Urpqj9joD96LRiK+LC62pQNs/zkXRJc+rHnxgkGp+vV703xzDrxRmv+V1YhCFfIGzs5nrVWtREIlyA=="
},
diff --git a/tests/consumers/spacegame/Program.cs b/tests/consumers/spacegame/Program.cs
new file mode 100644
index 0000000..2708779
--- /dev/null
+++ b/tests/consumers/spacegame/Program.cs
@@ -0,0 +1,8 @@
+using FinalFactory.Rendezvous.Client;
+using FinalFactory.Rendezvous.Contracts;
+using LiteNetLib;
+
+Console.WriteLine(
+ $"SpaceGame consumer: contract={ContractLimits.ContractVersion}, "
+ + $"client={typeof(RendezvousClientOptions).Assembly.GetName().Version}, "
+ + $"transport={typeof(NetManager).Assembly.GetName().Version}");
diff --git a/tests/consumers/spacegame/SpaceGame.Rendezvous.Consumer.csproj b/tests/consumers/spacegame/SpaceGame.Rendezvous.Consumer.csproj
new file mode 100644
index 0000000..ddd4d5d
--- /dev/null
+++ b/tests/consumers/spacegame/SpaceGame.Rendezvous.Consumer.csproj
@@ -0,0 +1,15 @@
+
+
+ Exe
+ net8.0
+ false
+ false
+ enable
+ enable
+ true
+
+
+
+
+
+
diff --git a/tests/consumers/unscouted/Program.cs b/tests/consumers/unscouted/Program.cs
new file mode 100644
index 0000000..0c9648e
--- /dev/null
+++ b/tests/consumers/unscouted/Program.cs
@@ -0,0 +1,8 @@
+using FinalFactory.Rendezvous.Client;
+using FinalFactory.Rendezvous.Contracts;
+using LiteNetLib;
+
+Console.WriteLine(
+ $"Unscouted consumer: contract={ContractLimits.ContractVersion}, "
+ + $"client={typeof(RendezvousClientOptions).Assembly.GetName().Version}, "
+ + $"transport={typeof(NetManager).Assembly.GetName().Version}");
diff --git a/tests/consumers/unscouted/Unscouted.Rendezvous.Consumer.csproj b/tests/consumers/unscouted/Unscouted.Rendezvous.Consumer.csproj
new file mode 100644
index 0000000..df63d85
--- /dev/null
+++ b/tests/consumers/unscouted/Unscouted.Rendezvous.Consumer.csproj
@@ -0,0 +1,16 @@
+
+
+ Exe
+ net8.0
+ false
+ false
+ enable
+ enable
+ true
+
+
+
+
+
+
+