Compare commits

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

Replace cosign-installer with the direct fetch the scanner already uses.
The action issues no API call on this path, but it is a composite action
resting on envsubst and a resolved runner.arch, neither of which this
runner has exercised. Asked for the version it bootstraps, it downloads
exactly cosign-linux-amd64 from the v3.0.6 release, compares it against
c956e5df..., and exits; that digest matches the release checksums file, so
fetching the asset directly verifies identically with nothing unproven
left before the one-way publication gate. The binary joins the PATH that
publish-release.sh already resolves dotnet through.
2026-08-22 22:10:00 +02:00
KyuubiYoru 747e3bb9c3 test(client): survive a starved stream handshake (#1)
quality-gate / quality (push) Successful in 2m40s
quality-gate / container (push) Successful in 1m51s
immutable-release / release (push) Failing after 5m35s
Opening an SSE stream is a single unretried request bounded by
RendezvousClientOptions.RequestTimeout. On the release runner the suite
shares a builder container with parallel image builds, so the loopback
handshake can lose its whole wall-clock budget to thread-pool starvation;
the client then reports a typed ServiceUnavailable that the test asserted
against as if it were the reset event.

Retry the open from the same replayable cursor, raise the per-request
budget to the contract ceiling, and drop protocol keepalives so a slow
interval between assertions cannot be mistaken for a session event. The
retry only fires on the transport failure the stream endpoint can never
produce as an envelope, is bounded, and yields the final failure verbatim,
so a genuinely broken stream still fails.
2026-08-22 21:42:21 +02:00
KyuubiYoru b235670afd fix(security): pin runtime to .NET 10.0.11 chiseled (#1)
quality-gate / quality (push) Successful in 2m42s
quality-gate / container (push) Successful in 1m55s
immutable-release / release (push) Failing after 5m39s
The 10.0.9 ASP.NET chiseled base shipped Microsoft.NETCore.App 10.0.9,
which the release trivy gate flags with six HIGH advisories:
CVE-2026-47302, CVE-2026-50524, CVE-2026-50528, CVE-2026-50651 and
CVE-2026-57108 (fixed in 10.0.10) plus CVE-2026-62901 (fixed in
10.0.11). Move the runtime stage to 10.0.11-noble-chiseled, pinned by
multi-architecture manifest digest as before, so every finding clears in
one step.

The build stage keeps SDK 10.0.301: the published server is framework
dependent, so the shipped runtime comes from the aspnet base alone and
the discarded builder layer is never scanned.
2026-08-22 21:20:30 +02:00
KyuubiYoru 562b8308b7 fix(release): run trivy from a pinned verified binary (#1)
quality-gate / quality (push) Successful in 2m47s
quality-gate / container (push) Successful in 1m51s
immutable-release / release (push) Failing after 5m40s
trivy-action checks its own repository out of github.com using the runner
token; on this self-hosted Gitea that token is a Gitea token, GitHub answers
"Bad credentials", and both scan steps die before trivy is installed.

Download the v0.69.3 release archive directly, verify it against a sha256
digest pinned inline, and unpack only the binary into .release-work/bin,
which is gitignored and excluded from the Docker build context. The gate
keeps its exact semantics: --exit-code 1, --severity HIGH,CRITICAL, table
output, unfixed vulnerabilities still in scope. The SPDX step writes the
same filename release_artifacts.py normalize-container-sbom consumes, and
TRIVY_CACHE_DIR keeps the vulnerability DB inside the work directory.
2026-08-22 21:06:21 +02:00
KyuubiYoru 4233f12368 fix(release): share sibling paths through the workspace (#1)
quality-gate / quality (push) Successful in 2m42s
quality-gate / container (push) Successful in 2m9s
immutable-release / release (push) Failing after 6m7s
The release job stopped at the bind-source gate. RUNNER_TEMP is
container-internal on this runner and no runner mount exposes it on the Docker
host, so no sibling container could ever share it; only the workspace is
host-mounted.

Move every path shared between the runner's shell steps and its sibling
containers under $GITHUB_WORKSPACE/.release-work: the release directory, the
release builder's HOME and NuGet cache, both candidate image tars, and the
container SPDX inventory. Resolution now maps the workspace alone to its host
path, and each sibling binds that source at $GITHUB_WORKSPACE and works from
there instead of /source, so a shared path is the same string on both sides of
the boundary. publish-release.sh follows with a single bind and requires the
release directory to sit inside the workspace.

Ignore .release-work in Git so the tag gate's cleanliness check stays true while
artifacts accumulate, and in Docker so artifacts written between the two
candidate builds cannot alter the build context the byte-comparison gate
depends on. Skip it in the dependency inventory as well, keeping the restored
package cache out of the license policy scan.
2026-08-22 20:31:18 +02:00
8 changed files with 267 additions and 87 deletions
+1
View File
@@ -6,6 +6,7 @@
.agents
**/bin
**/obj
.release-work
TestResults
deploy/compose/secrets
deploy/compose/.smoke.env
+112 -61
View File
@@ -20,38 +20,36 @@ jobs:
with:
fetch-depth: 0
- name: Resolve host bind sources for sibling containers
- name: Resolve the host bind source for sibling containers
shell: bash
run: |
set -euo pipefail
# This job's steps run inside the runner container while every
# `docker run` starts a sibling container on the host daemon, so bind
# sources must be host paths. Resolve them once from the runner's own
# mounts and reuse them in every later step.
# sources must be host paths. The workspace is the only runner mount
# backed by the host, so every path shared with a sibling lives under
# it and a single bind source is resolved once here. Siblings mount
# that source at $GITHUB_WORKSPACE, which keeps every shared path the
# same string on both sides of the boundary.
echo "RENDEZVOUS_WORK_DIR=$GITHUB_WORKSPACE/.release-work" >>"$GITHUB_ENV"
if ! mounts="$(docker inspect "$HOSTNAME" 2>/dev/null | jq -c '.[0].Mounts')"; then
echo "Runner is not containerized; using workspace and temp paths as host paths."
echo "Runner is not containerized; using the workspace path as its own host path."
echo "RENDEZVOUS_WORKSPACE_SOURCE=$GITHUB_WORKSPACE" >>"$GITHUB_ENV"
echo "RENDEZVOUS_TEMP_SOURCE=$RUNNER_TEMP" >>"$GITHUB_ENV"
exit 0
fi
resolve_host_path() {
jq -er --arg path "$1" '
[ .[]
| .Destination as $destination
| select($path == $destination
or ($path | startswith($destination + "/"))) ]
| if length == 0 then
error("No runner mount exposes \($path) on the Docker host.")
else
sort_by(.Destination | length) | last
end
| (.Destination | length) as $prefix
| .Source + $path[$prefix:]' <<<"$mounts"
}
workspace_source="$(resolve_host_path "$GITHUB_WORKSPACE")"
temp_source="$(resolve_host_path "$RUNNER_TEMP")"
workspace_source="$(jq -er --arg path "$GITHUB_WORKSPACE" '
[ .[]
| .Destination as $destination
| select($path == $destination
or ($path | startswith($destination + "/"))) ]
| if length == 0 then
error("No runner mount exposes \($path) on the Docker host.")
else
sort_by(.Destination | length) | last
end
| (.Destination | length) as $prefix
| .Source + $path[$prefix:]' <<<"$mounts")"
echo "RENDEZVOUS_WORKSPACE_SOURCE=$workspace_source" >>"$GITHUB_ENV"
echo "RENDEZVOUS_TEMP_SOURCE=$temp_source" >>"$GITHUB_ENV"
- name: Install pinned .NET SDKs
uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
@@ -94,20 +92,19 @@ jobs:
--target release-builder \
--load \
--tag "$release_builder" .
mkdir -p "${RUNNER_TEMP}/release-home" "${RUNNER_TEMP}/nuget"
release_dir="$RENDEZVOUS_WORK_DIR/release/$version"
mkdir -p "$RENDEZVOUS_WORK_DIR/release-home" "$RENDEZVOUS_WORK_DIR/nuget"
docker run --rm \
--user "$(id -u):$(id -g)" \
--env HOME="${RUNNER_TEMP}/release-home" \
--env NUGET_PACKAGES="${RUNNER_TEMP}/nuget" \
--volume "$RENDEZVOUS_WORKSPACE_SOURCE:/source" \
--volume "$RENDEZVOUS_TEMP_SOURCE:${RUNNER_TEMP}" \
--workdir /source \
--env HOME="$RENDEZVOUS_WORK_DIR/release-home" \
--env NUGET_PACKAGES="$RENDEZVOUS_WORK_DIR/nuget" \
--volume "$RENDEZVOUS_WORKSPACE_SOURCE:$GITHUB_WORKSPACE" \
--workdir "$GITHUB_WORKSPACE" \
"$release_builder" \
./scripts/build-release.sh "$version" "${RUNNER_TEMP}/release/$version"
./scripts/verify-real-consumers.sh "$version" "${RUNNER_TEMP}/release/$version"
./scripts/build-release.sh "$version" "$release_dir"
./scripts/verify-real-consumers.sh "$version" "$release_dir"
echo "RENDEZVOUS_VERSION=$version" >>"$GITHUB_ENV"
echo "RENDEZVOUS_RELEASE_DIR=${RUNNER_TEMP}/release/$version" >>"$GITHUB_ENV"
echo "RENDEZVOUS_RELEASE_DIR_SOURCE=${RENDEZVOUS_TEMP_SOURCE}/release/$version" >>"$GITHUB_ENV"
echo "RENDEZVOUS_RELEASE_DIR=$release_dir" >>"$GITHUB_ENV"
echo "RENDEZVOUS_RELEASE_BUILDER=$release_builder" >>"$GITHUB_ENV"
- name: Build exact container candidate
@@ -124,8 +121,10 @@ jobs:
--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"
# .release-work is excluded from the build context, so the tar written
# by the first build cannot change the context the second one sees.
image_one="$RENDEZVOUS_WORK_DIR/rendezvous-image-1.tar"
image_two="$RENDEZVOUS_WORK_DIR/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" \
@@ -136,9 +135,8 @@ jobs:
buildkit_version="$(docker buildx inspect --bootstrap | sed -n 's/.*BuildKit version: *//p' | sed -n '1p')"
docker run --rm \
--user "$(id -u):$(id -g)" \
--volume "$RENDEZVOUS_WORKSPACE_SOURCE:/source" \
--volume "$RENDEZVOUS_TEMP_SOURCE:${RUNNER_TEMP}" \
--workdir /source \
--volume "$RENDEZVOUS_WORKSPACE_SOURCE:$GITHUB_WORKSPACE" \
--workdir "$GITHUB_WORKSPACE" \
"$RENDEZVOUS_RELEASE_BUILDER" \
python3 eng/release_artifacts.py record-container-build \
--provenance "$RENDEZVOUS_RELEASE_DIR/release-provenance.json" \
@@ -184,8 +182,8 @@ jobs:
--env RENDEZVOUS_SMOKE_HTTP_URL=http://127.0.0.1:8080/ \
--env RENDEZVOUS_SMOKE_UDP_ENDPOINT=127.0.0.1:9050 \
--env RENDEZVOUS_PUBLISHER_CREDENTIAL \
--volume "$runner_workspace_source:/source:ro" \
--workdir /source \
--volume "$runner_workspace_source:$GITHUB_WORKSPACE:ro" \
--workdir "$GITHUB_WORKSPACE" \
"$RENDEZVOUS_RELEASE_BUILDER" \
bash -lc '
for attempt in {1..180}; do
@@ -198,23 +196,52 @@ jobs:
exit 1
}
- name: Install pinned vulnerability scanner
shell: bash
run: |
set -euo pipefail
# trivy-action checks its own repository out of github.com with the
# runner token, which this Gitea instance cannot mint, so the release
# binary is fetched directly instead. The archive URL and its digest
# are pinned inline: fetching the published checksums file at run time
# would only prove the asset matches whatever the release currently
# serves, which is exactly what pinning has to rule out.
trivy_bin_dir="$RENDEZVOUS_WORK_DIR/bin"
trivy_archive="$RENDEZVOUS_WORK_DIR/trivy_0.69.3_Linux-64bit.tar.gz"
# .release-work is gitignored and excluded from the Docker build
# context, so nothing unpacked here can reach an image layer.
mkdir -p "$trivy_bin_dir" "$RENDEZVOUS_WORK_DIR/trivy-cache"
curl --fail --location --silent --show-error --output "$trivy_archive" \
https://github.com/aquasecurity/trivy/releases/download/v0.69.3/trivy_0.69.3_Linux-64bit.tar.gz
echo "1816b632dfe529869c740c0913e36bd1629cb7688bd5634f4a858c1d57c88b75 $trivy_archive" \
| sha256sum --check --strict -
tar --extract --file "$trivy_archive" --directory "$trivy_bin_dir" trivy
rm -f "$trivy_archive"
chmod +x "$trivy_bin_dir/trivy"
echo "RENDEZVOUS_TRIVY=$trivy_bin_dir/trivy" >>"$GITHUB_ENV"
echo "TRIVY_CACHE_DIR=$RENDEZVOUS_WORK_DIR/trivy-cache" >>"$GITHUB_ENV"
"$trivy_bin_dir/trivy" --version
- 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
shell: bash
run: |
set -euo pipefail
# Unfixed vulnerabilities stay in scope: the flag that would drop them
# is never passed, so the gate keeps trivy's fail-closed default.
"$RENDEZVOUS_TRIVY" image \
--exit-code 1 \
--severity HIGH,CRITICAL \
--format table \
"git.finalfactory.de/heikyu/rendezvous:${RENDEZVOUS_VERSION}"
- 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
shell: bash
run: |
set -euo pipefail
"$RENDEZVOUS_TRIVY" image \
--format spdx-json \
--output "$RENDEZVOUS_RELEASE_DIR/FinalFactory.Rendezvous.Container.$RENDEZVOUS_VERSION.spdx.json" \
"git.finalfactory.de/heikyu/rendezvous:${RENDEZVOUS_VERSION}"
- name: Finalize checksums over the publish-ready candidate
shell: bash
@@ -223,9 +250,8 @@ jobs:
source_date_epoch="$(git show -s --format=%ct HEAD)"
docker run --rm \
--user "$(id -u):$(id -g)" \
--volume "$RENDEZVOUS_WORKSPACE_SOURCE:/source" \
--volume "$RENDEZVOUS_TEMP_SOURCE:${RUNNER_TEMP}" \
--workdir /source \
--volume "$RENDEZVOUS_WORKSPACE_SOURCE:$GITHUB_WORKSPACE" \
--workdir "$GITHUB_WORKSPACE" \
"$RENDEZVOUS_RELEASE_BUILDER" \
bash -c 'python3 eng/release_artifacts.py normalize-container-sbom \
--file "$1/FinalFactory.Rendezvous.Container.$2.spdx.json" \
@@ -236,7 +262,13 @@ jobs:
_ "$RENDEZVOUS_RELEASE_DIR" "$RENDEZVOUS_VERSION" "$GITHUB_SHA" "$source_date_epoch"
- name: Preserve verified candidate artifacts
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
# upload-artifact@v4 speaks the v2 artifacts API, which this Gitea only
# answers at its v3-era shape, so v4 aborts before uploading anything.
# Keeping the candidate is a pre-publish debugging backstop, not a
# release gate: publish-release.sh attaches the same files as Gitea
# release assets, so a failure here must never block a release.
continue-on-error: true
uses: actions/upload-artifact@ff15f0306b3f739f7b6fd43fb5d26cd321bd4de5 # v3.2.1
with:
name: rendezvous-${{ env.RENDEZVOUS_VERSION }}
path: ${{ env.RENDEZVOUS_RELEASE_DIR }}
@@ -244,9 +276,29 @@ jobs:
retention-days: 30
- name: Install pinned signing client
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
with:
cosign-release: v3.0.6
shell: bash
run: |
set -euo pipefail
# cosign-installer issues no API call on the pinned path, but it is a
# composite action resting on `envsubst` and a resolved runner.arch,
# neither of which this runner has ever exercised. Asked for the same
# version it bootstraps, the action downloads exactly this asset and
# checks it against exactly this digest before declaring itself done,
# so fetching it directly verifies identically with nothing unproven
# left in the path. The digest is pinned inline for the reason the
# scanner's is: a checksums file fetched at run time only proves the
# asset matches whatever the release currently serves.
cosign_bin_dir="$RENDEZVOUS_WORK_DIR/bin"
mkdir -p "$cosign_bin_dir"
curl --fail --location --silent --show-error --output "$cosign_bin_dir/cosign" \
https://github.com/sigstore/cosign/releases/download/v3.0.6/cosign-linux-amd64
echo "c956e5dfcac53d52bcf058360d579472f0c1d2d9b69f55209e256fe7783f4c74 $cosign_bin_dir/cosign" \
| sha256sum --check --strict -
chmod +x "$cosign_bin_dir/cosign"
# publish-release.sh resolves cosign through `command -v`, so the
# directory joins the PATH the same way the action would have added it.
echo "$cosign_bin_dir" >>"$GITHUB_PATH"
"$cosign_bin_dir/cosign" version
- name: Publish once, sign, attest, and create release
shell: bash
@@ -256,5 +308,4 @@ jobs:
COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
RENDEZVOUS_WORKSPACE_SOURCE: ${{ env.RENDEZVOUS_WORKSPACE_SOURCE }}
RENDEZVOUS_RELEASE_DIR_SOURCE: ${{ env.RENDEZVOUS_RELEASE_DIR_SOURCE }}
run: ./scripts/publish-release.sh "$RENDEZVOUS_VERSION" "$RENDEZVOUS_RELEASE_DIR"
+1
View File
@@ -8,6 +8,7 @@ TestResults/
*.userosscache
deploy/compose/.smoke.env
artifacts/
.release-work/
__pycache__/
*.pyc
deploy/compose/secrets/*
+1 -1
View File
@@ -21,7 +21,7 @@ RUN dotnet publish src/FinalFactory.Rendezvous.Server/FinalFactory.Rendezvous.Se
/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
FROM mcr.microsoft.com/dotnet/aspnet:10.0.11-noble-chiseled@sha256:0839314d08bb65da369135389a5d8291f75ace587fbb0488f469eb92c62eef68 AS runtime
ENV ASPNETCORE_HTTP_PORTS=8080 \
DOTNET_EnableDiagnostics=0 \
+4 -1
View File
@@ -30,7 +30,10 @@ def load_json(path: pathlib.Path):
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):
if any(
part in {"bin", "obj", "artifacts", ".release-work"}
for part in lock_path.parts
):
continue
lock = load_json(lock_path)
for framework in lock.get("dependencies", {}).values():
+16 -7
View File
@@ -11,11 +11,21 @@ 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}"
# Sibling-container runners execute this script inside a container while
# `docker run` starts containers on the host daemon, so bind sources must be
# host paths. The workflow resolves them once and exports them; a direct host
# run keeps the local paths.
# `docker run` starts containers on the host daemon, so the bind source must be
# a host path. The workflow resolves the workspace once and exports it; a direct
# host run keeps the local path. Only the workspace is bound, so the release
# directory has to live inside it, and binding it back onto its own path keeps
# every shared path identical on both sides of the boundary.
workspace_source="${RENDEZVOUS_WORKSPACE_SOURCE:-$root}"
release_dir_source="${RENDEZVOUS_RELEASE_DIR_SOURCE:-$release_dir}"
[[ -d "$release_dir" ]] || {
echo "Release directory does not exist: $release_dir" >&2
exit 1
}
release_dir="$(cd "$release_dir" && pwd)"
if [[ "$release_dir" != "$root"/* ]]; then
echo "Release directory must live inside the workspace: $release_dir" >&2
exit 1
fi
docker_config="$(mktemp -d)"
curl_config="$(mktemp)"
release_request=""
@@ -52,9 +62,8 @@ cosign public-key --key env://COSIGN_PRIVATE_KEY >/dev/null || {
run_release_builder() {
docker run --rm \
--user "$(id -u):$(id -g)" \
--volume "$workspace_source:/source:ro" \
--volume "$release_dir_source:$release_dir" \
--workdir /source \
--volume "$workspace_source:$root" \
--workdir "$root" \
"$release_builder" "$@"
}
@@ -1,6 +1,7 @@
using System.Diagnostics;
using System.Net;
using System.Net.Sockets;
using System.Runtime.CompilerServices;
using FinalFactory.Rendezvous.Client;
using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.Abuse;
@@ -146,13 +147,19 @@ public sealed class RendezvousClientIntegrationTests
public async Task BrowserStreamResetsInvalidCursorReplaysReconnectAndReleasesConnections()
{
await using ClientTestHost host = await ClientTestHost.StartAsync();
RendezvousPublisherClient publisher = new(host.HttpClient);
RendezvousSessionBrowserClient browser = new(
host.HttpClient,
new RendezvousClientOptions
{
RequestTimeout = TimeSpan.FromSeconds(15),
});
// Budgets are sized for the release pipeline, not for a developer machine. That job
// runs this suite inside a builder container on a busy act_runner host, alongside
// parallel image builds, so the in-process Kestrel host and the HttpClient driving it
// share a thread pool that is routinely starved. A loopback round trip that costs
// microseconds locally can then cost seconds - and the first stream request in the
// process additionally pays the one-time JIT and serializer warm-up of the SSE path.
// 30s is the ceiling RendezvousClientOptions.Validate permits for RequestTimeout.
RendezvousClientOptions loadedRunner = new()
{
RequestTimeout = TimeSpan.FromSeconds(30),
};
RendezvousPublisherClient publisher = new(host.HttpClient, loadedRunner);
RendezvousSessionBrowserClient browser = new(host.HttpClient, loadedRunner);
PublishedSession session = AssertSuccess(await publisher.RegisterAsync(
CreateRegistration(200),
host.PublisherCredential));
@@ -161,18 +168,22 @@ public sealed class RendezvousClientIntegrationTests
BrowseSessionsResponse snapshot = AssertSuccess(await browser.BrowseAsync(request));
Assert.False(string.IsNullOrWhiteSpace(snapshot.StreamCursor));
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(30));
await using (IAsyncEnumerator<RendezvousClientResult<SessionStreamEvent>> invalid = browser
.StreamAsync(request, CorruptCursor(snapshot.StreamCursor), timeout.Token)
using CancellationTokenSource timeout = new(TimeSpan.FromMinutes(2));
await using (IAsyncEnumerator<RendezvousClientResult<SessionStreamEvent>> invalid =
StreamWithOpenRetry(
browser,
request,
CorruptCursor(snapshot.StreamCursor),
timeout.Token)
.GetAsyncEnumerator(timeout.Token))
{
Assert.True(await invalid.MoveNextAsync());
Assert.Equal(SessionStreamEventKind.Reset, AssertSuccess(invalid.Current).Kind);
Assert.False(await invalid.MoveNextAsync());
}
await using IAsyncEnumerator<RendezvousClientResult<SessionStreamEvent>> events = browser
.StreamAsync(request, snapshot.StreamCursor, timeout.Token)
.GetAsyncEnumerator(timeout.Token);
await using IAsyncEnumerator<RendezvousClientResult<SessionStreamEvent>> events =
StreamWithOpenRetry(browser, request, snapshot.StreamCursor, timeout.Token)
.GetAsyncEnumerator(timeout.Token);
Task<bool> upsertPending = events.MoveNextAsync().AsTask();
Assert.True((await publisher.UpdateAsync(
session,
@@ -190,8 +201,8 @@ public sealed class RendezvousClientIntegrationTests
Assert.Equal(SessionStreamEventKind.SessionUpsert, upsert.Kind);
Assert.Equal("Live update", upsert.Session!.DisplayName);
await using (IAsyncEnumerator<RendezvousClientResult<SessionStreamEvent>> replay = browser
.StreamAsync(request, snapshot.StreamCursor, timeout.Token)
await using (IAsyncEnumerator<RendezvousClientResult<SessionStreamEvent>> replay =
StreamWithOpenRetry(browser, request, snapshot.StreamCursor, timeout.Token)
.GetAsyncEnumerator(timeout.Token))
{
Assert.True(await replay.MoveNextAsync());
@@ -212,6 +223,68 @@ public sealed class RendezvousClientIntegrationTests
Assert.Equal(session.ListingId, remove.ListingId);
}
// Opening an event stream is a single, unretried request in the SDK, so a handshake that
// loses its wall-clock budget on a saturated runner surfaces as a typed ServiceUnavailable
// first element instead of the expected event. Reopening is semantically free: every call
// site passes a replayable snapshot cursor, so a reopened stream observes exactly the
// events the first attempt would have delivered.
//
// The retry cannot hide a product regression. StreamSessions never answers with a
// ServiceUnavailable envelope - its only rejections are InvalidRequest,
// UnsupportedContractVersion, CapacityExceeded and RateLimited - so this code path is
// reachable only from the transport catch in RendezvousHttpTransport.OpenStreamAsync,
// i.e. a timed-out or dropped handshake. Attempts are bounded, and the final failure is
// yielded verbatim, so a stream that is genuinely unopenable still fails the test with the
// original message.
private const int StreamOpenAttempts = 3;
private static async IAsyncEnumerable<RendezvousClientResult<SessionStreamEvent>> StreamWithOpenRetry(
RendezvousSessionBrowserClient browser,
BrowseSessionsRequest request,
string streamCursor,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
for (int attempt = 1; ; attempt++)
{
bool reopen = false;
await using (IAsyncEnumerator<RendezvousClientResult<SessionStreamEvent>> source = browser
.StreamAsync(request, streamCursor, cancellationToken)
.GetAsyncEnumerator(cancellationToken))
{
bool opening = true;
while (await source.MoveNextAsync())
{
RendezvousClientResult<SessionStreamEvent> current = source.Current;
if (opening
&& !current.IsSuccess
&& current.Error == RendezvousErrorCode.ServiceUnavailable
&& attempt < StreamOpenAttempts)
{
reopen = true;
break;
}
opening = false;
// Keepalives are protocol filler with no session semantics. The server emits
// one whenever a subscription idles for its keepalive interval, which a slow
// runner reaches between the assertions below; dropping them keeps the
// reset/upsert/remove expectations exact.
if (current.IsSuccess && current.Value!.Kind == SessionStreamEventKind.Keepalive)
{
continue;
}
yield return current;
}
}
if (!reopen)
{
yield break;
}
}
}
private static string CorruptCursor(string cursor)
{
char replacement = cursor[^1] == 'a' ? 'b' : 'a';
@@ -86,8 +86,50 @@ public sealed class ReleaseCompatibilityTests
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.DoesNotContain("aquasecurity/trivy-action", workflow, StringComparison.Ordinal);
Assert.Contains(
"https://github.com/aquasecurity/trivy/releases/download/v0.69.3/trivy_0.69.3_Linux-64bit.tar.gz",
workflow,
StringComparison.Ordinal);
Assert.Contains(
"1816b632dfe529869c740c0913e36bd1629cb7688bd5634f4a858c1d57c88b75",
workflow,
StringComparison.Ordinal);
Assert.Contains("sha256sum --check --strict", workflow, StringComparison.Ordinal);
Assert.Contains("TRIVY_CACHE_DIR=$RENDEZVOUS_WORK_DIR/trivy-cache", workflow, StringComparison.Ordinal);
// Gitea answers the v3-era artifacts API only, so upload-artifact stays on
// v3 and never gates the release it is only meant to help debug.
Assert.DoesNotContain(
"actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02",
workflow,
StringComparison.Ordinal);
Assert.Contains(
"actions/upload-artifact@ff15f0306b3f739f7b6fd43fb5d26cd321bd4de5 # v3.2.1",
workflow,
StringComparison.Ordinal);
Assert.Contains("continue-on-error: true", workflow, StringComparison.Ordinal);
// The signing client is fetched the way the scanner is: a pinned asset
// checked against a pinned digest, with no action resolved off github.com.
Assert.DoesNotContain("sigstore/cosign-installer", workflow, StringComparison.Ordinal);
Assert.Contains(
"https://github.com/sigstore/cosign/releases/download/v3.0.6/cosign-linux-amd64",
workflow,
StringComparison.Ordinal);
Assert.Contains(
"c956e5dfcac53d52bcf058360d579472f0c1d2d9b69f55209e256fe7783f4c74",
workflow,
StringComparison.Ordinal);
Assert.Contains("echo \"$cosign_bin_dir\" >>\"$GITHUB_PATH\"", workflow, StringComparison.Ordinal);
Assert.Contains("--exit-code 1", workflow, StringComparison.Ordinal);
Assert.Contains("--severity HIGH,CRITICAL", workflow, StringComparison.Ordinal);
Assert.DoesNotContain("--ignore-unfixed", workflow, StringComparison.Ordinal);
Assert.Contains("--format spdx-json", workflow, StringComparison.Ordinal);
Assert.Contains(
"--output \"$RENDEZVOUS_RELEASE_DIR/FinalFactory.Rendezvous.Container.$RENDEZVOUS_VERSION.spdx.json\"",
workflow,
StringComparison.Ordinal);
Assert.Contains("normalize-container-sbom", workflow, StringComparison.Ordinal);
Assert.Contains("finalize-release-candidate.sh", workflow, StringComparison.Ordinal);
Assert.Contains("verify-real-consumers.sh", workflow, StringComparison.Ordinal);