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: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:p" "$root/eng/Versions.props" +} +base_property() { + git -C "$root" show "$base:eng/Versions.props" \ + | sed -n "s:.*<$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 + + + + + + +