Compare commits

...
7 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
KyuubiYoru c5bc9651e5 docs(release): date the 1.0.0 changelog entry (#1)
quality-gate / quality (push) Successful in 2m39s
immutable-release / release (push) Failing after 1m26s
quality-gate / container (push) Successful in 1m53s
2026-08-22 19:48:01 +02:00
KyuubiYoru d41fa0c7e9 fix(release): repair tag gate and sibling-container publication (#1)
The release workflow could not complete on the self-hosted sibling-container
runner. Four defects are corrected together because three of them share the
same release.yml plumbing.

Exclude the tag under release from the prior-tag probe. check-release-tag.sh
already requires the tag to point at HEAD, so the unfiltered listing always
matched itself and rejected every first release before the initial baseline
branch could run.

Resolve host bind sources once per job. Steps run inside the runner container
while docker run starts siblings on the host daemon, so GITHUB_WORKSPACE and
RUNNER_TEMP are not usable as bind sources. A new step maps both to host paths
through the runner's own mounts and exports them; the build, provenance, smoke,
finalize, and publish steps reuse them, and publish-release.sh receives them by
environment instead of repeating the inspection. Bind destinations stay at the
container-internal paths so runner-side reads keep working unchanged.

Prove the signing material before the first push. Gitea package versions are
immutable, so a missing or undecryptable cosign key must abort ahead of the
require_absent gate rather than after packages and the image are published.

Authenticate the pinned consumer fetches. Both consumer repositories are
private, so anonymous fetches fail; the release token is passed to the
verification step and applied as a URL-scoped extra header through git's
config environment, keeping it out of argv, remotes, and on-disk config.
Anonymous fetch remains the fallback for local runs.
2026-08-22 19:47:57 +02:00
10 changed files with 319 additions and 65 deletions
+1
View File
@@ -6,6 +6,7 @@
.agents .agents
**/bin **/bin
**/obj **/obj
.release-work
TestResults TestResults
deploy/compose/secrets deploy/compose/secrets
deploy/compose/.smoke.env deploy/compose/.smoke.env
+131 -41
View File
@@ -20,6 +20,37 @@ jobs:
with: with:
fetch-depth: 0 fetch-depth: 0
- 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. 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 the workspace path as its own host path."
echo "RENDEZVOUS_WORKSPACE_SOURCE=$GITHUB_WORKSPACE" >>"$GITHUB_ENV"
exit 0
fi
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"
- name: Install pinned .NET SDKs - name: Install pinned .NET SDKs
uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1 uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
with: with:
@@ -36,14 +67,19 @@ jobs:
- name: Validate tag and produce reproducible artifacts - name: Validate tag and produce reproducible artifacts
shell: bash shell: bash
env:
RENDEZVOUS_RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
run: | run: |
set -euo pipefail set -euo pipefail
version="${GITHUB_REF_NAME#v}" version="${GITHUB_REF_NAME#v}"
./scripts/check-release-tag.sh "$GITHUB_REF_NAME" ./scripts/check-release-tag.sh "$GITHUB_REF_NAME"
previous_tag="$(git tag --merged HEAD^ --list 'v*.*.*' --sort=-version:refname | sed -n '1p')" previous_tag="$(git tag --merged HEAD^ --list 'v*.*.*' --sort=-version:refname | sed -n '1p')"
# check-release-tag.sh already required this tag to point at HEAD, so
# it must be excluded before asking whether any earlier release exists.
unrelated_tag="$(git tag --list 'v*.*.*' | grep -vFx "$GITHUB_REF_NAME" | sed -n '1p' || true)"
if [[ -n "$previous_tag" ]]; then if [[ -n "$previous_tag" ]]; then
./scripts/check-compatibility.sh "$previous_tag" ./scripts/check-compatibility.sh "$previous_tag"
elif [[ -n "$(git tag --list 'v*.*.*' | sed -n '1p')" ]]; then elif [[ -n "$unrelated_tag" ]]; then
echo "No prior release tag is an ancestor of $GITHUB_REF_NAME." >&2 echo "No prior release tag is an ancestor of $GITHUB_REF_NAME." >&2
exit 1 exit 1
else else
@@ -56,19 +92,19 @@ jobs:
--target release-builder \ --target release-builder \
--load \ --load \
--tag "$release_builder" . --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 \ docker run --rm \
--user "$(id -u):$(id -g)" \ --user "$(id -u):$(id -g)" \
--env HOME="${RUNNER_TEMP}/release-home" \ --env HOME="$RENDEZVOUS_WORK_DIR/release-home" \
--env NUGET_PACKAGES="${RUNNER_TEMP}/nuget" \ --env NUGET_PACKAGES="$RENDEZVOUS_WORK_DIR/nuget" \
--volume "$GITHUB_WORKSPACE:/source" \ --volume "$RENDEZVOUS_WORKSPACE_SOURCE:$GITHUB_WORKSPACE" \
--volume "${RUNNER_TEMP}:${RUNNER_TEMP}" \ --workdir "$GITHUB_WORKSPACE" \
--workdir /source \
"$release_builder" \ "$release_builder" \
./scripts/build-release.sh "$version" "${RUNNER_TEMP}/release/$version" ./scripts/build-release.sh "$version" "$release_dir"
./scripts/verify-real-consumers.sh "$version" "${RUNNER_TEMP}/release/$version" ./scripts/verify-real-consumers.sh "$version" "$release_dir"
echo "RENDEZVOUS_VERSION=$version" >>"$GITHUB_ENV" echo "RENDEZVOUS_VERSION=$version" >>"$GITHUB_ENV"
echo "RENDEZVOUS_RELEASE_DIR=${RUNNER_TEMP}/release/$version" >>"$GITHUB_ENV" echo "RENDEZVOUS_RELEASE_DIR=$release_dir" >>"$GITHUB_ENV"
echo "RENDEZVOUS_RELEASE_BUILDER=$release_builder" >>"$GITHUB_ENV" echo "RENDEZVOUS_RELEASE_BUILDER=$release_builder" >>"$GITHUB_ENV"
- name: Build exact container candidate - name: Build exact container candidate
@@ -85,8 +121,10 @@ jobs:
--build-arg SOURCE_REVISION_ID="$GITHUB_SHA" --build-arg SOURCE_REVISION_ID="$GITHUB_SHA"
) )
release_tag="git.finalfactory.de/heikyu/rendezvous:${RENDEZVOUS_VERSION}" release_tag="git.finalfactory.de/heikyu/rendezvous:${RENDEZVOUS_VERSION}"
image_one="${RUNNER_TEMP}/rendezvous-image-1.tar" # .release-work is excluded from the build context, so the tar written
image_two="${RUNNER_TEMP}/rendezvous-image-2.tar" # 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" \ docker buildx build "${common[@]}" --tag "$release_tag" \
--output "type=docker,dest=$image_one,rewrite-timestamp=true" . --output "type=docker,dest=$image_one,rewrite-timestamp=true" .
docker buildx build "${common[@]}" --tag "$release_tag" \ docker buildx build "${common[@]}" --tag "$release_tag" \
@@ -97,9 +135,8 @@ jobs:
buildkit_version="$(docker buildx inspect --bootstrap | sed -n 's/.*BuildKit version: *//p' | sed -n '1p')" buildkit_version="$(docker buildx inspect --bootstrap | sed -n 's/.*BuildKit version: *//p' | sed -n '1p')"
docker run --rm \ docker run --rm \
--user "$(id -u):$(id -g)" \ --user "$(id -u):$(id -g)" \
--volume "$GITHUB_WORKSPACE:/source" \ --volume "$RENDEZVOUS_WORKSPACE_SOURCE:$GITHUB_WORKSPACE" \
--volume "${RUNNER_TEMP}:${RUNNER_TEMP}" \ --workdir "$GITHUB_WORKSPACE" \
--workdir /source \
"$RENDEZVOUS_RELEASE_BUILDER" \ "$RENDEZVOUS_RELEASE_BUILDER" \
python3 eng/release_artifacts.py record-container-build \ python3 eng/release_artifacts.py record-container-build \
--provenance "$RENDEZVOUS_RELEASE_DIR/release-provenance.json" \ --provenance "$RENDEZVOUS_RELEASE_DIR/release-provenance.json" \
@@ -131,9 +168,7 @@ jobs:
port_suffix="$(( ${GITHUB_RUN_ID:-$$} % 10000 ))" port_suffix="$(( ${GITHUB_RUN_ID:-$$} % 10000 ))"
export RENDEZVOUS_HTTP_HOST_PORT="$(( 20000 + port_suffix ))" export RENDEZVOUS_HTTP_HOST_PORT="$(( 20000 + port_suffix ))"
export RENDEZVOUS_UDP_HOST_PORT="$(( 40000 + port_suffix ))" export RENDEZVOUS_UDP_HOST_PORT="$(( 40000 + port_suffix ))"
runner_workspace_source="$(docker inspect "$HOSTNAME" | jq -er \ runner_workspace_source="${RENDEZVOUS_WORKSPACE_SOURCE:?host workspace bind source was not resolved}"
--arg destination "$GITHUB_WORKSPACE" \
'.[0].Mounts[] | select(.Destination == $destination) | .Source')"
export RENDEZVOUS_CONFIG_SOURCE="$runner_workspace_source/deploy/compose/appsettings.Production.json" export RENDEZVOUS_CONFIG_SOURCE="$runner_workspace_source/deploy/compose/appsettings.Production.json"
export RENDEZVOUS_SECRET_SOURCE="$runner_workspace_source/deploy/compose/secrets/signing-key" export RENDEZVOUS_SECRET_SOURCE="$runner_workspace_source/deploy/compose/secrets/signing-key"
export RENDEZVOUS_IMAGE="git.finalfactory.de/heikyu/rendezvous:${RENDEZVOUS_VERSION}" export RENDEZVOUS_IMAGE="git.finalfactory.de/heikyu/rendezvous:${RENDEZVOUS_VERSION}"
@@ -147,8 +182,8 @@ jobs:
--env RENDEZVOUS_SMOKE_HTTP_URL=http://127.0.0.1:8080/ \ --env RENDEZVOUS_SMOKE_HTTP_URL=http://127.0.0.1:8080/ \
--env RENDEZVOUS_SMOKE_UDP_ENDPOINT=127.0.0.1:9050 \ --env RENDEZVOUS_SMOKE_UDP_ENDPOINT=127.0.0.1:9050 \
--env RENDEZVOUS_PUBLISHER_CREDENTIAL \ --env RENDEZVOUS_PUBLISHER_CREDENTIAL \
--volume "$runner_workspace_source:/source:ro" \ --volume "$runner_workspace_source:$GITHUB_WORKSPACE:ro" \
--workdir /source \ --workdir "$GITHUB_WORKSPACE" \
"$RENDEZVOUS_RELEASE_BUILDER" \ "$RENDEZVOUS_RELEASE_BUILDER" \
bash -lc ' bash -lc '
for attempt in {1..180}; do for attempt in {1..180}; do
@@ -161,23 +196,52 @@ jobs:
exit 1 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 - name: Scan candidate for high and critical vulnerabilities
uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # v0.35.0, post-incident safe SHA shell: bash
with: run: |
image-ref: git.finalfactory.de/heikyu/rendezvous:${{ env.RENDEZVOUS_VERSION }} set -euo pipefail
version: v0.69.3 # Unfixed vulnerabilities stay in scope: the flag that would drop them
format: table # is never passed, so the gate keeps trivy's fail-closed default.
exit-code: "1" "$RENDEZVOUS_TRIVY" image \
ignore-unfixed: false --exit-code 1 \
severity: HIGH,CRITICAL --severity HIGH,CRITICAL \
--format table \
"git.finalfactory.de/heikyu/rendezvous:${RENDEZVOUS_VERSION}"
- name: Generate container SPDX inventory - name: Generate container SPDX inventory
uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # v0.35.0, post-incident safe SHA shell: bash
with: run: |
image-ref: git.finalfactory.de/heikyu/rendezvous:${{ env.RENDEZVOUS_VERSION }} set -euo pipefail
version: v0.69.3 "$RENDEZVOUS_TRIVY" image \
format: spdx-json --format spdx-json \
output: ${{ env.RENDEZVOUS_RELEASE_DIR }}/FinalFactory.Rendezvous.Container.${{ env.RENDEZVOUS_VERSION }}.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 - name: Finalize checksums over the publish-ready candidate
shell: bash shell: bash
@@ -186,9 +250,8 @@ jobs:
source_date_epoch="$(git show -s --format=%ct HEAD)" source_date_epoch="$(git show -s --format=%ct HEAD)"
docker run --rm \ docker run --rm \
--user "$(id -u):$(id -g)" \ --user "$(id -u):$(id -g)" \
--volume "$GITHUB_WORKSPACE:/source" \ --volume "$RENDEZVOUS_WORKSPACE_SOURCE:$GITHUB_WORKSPACE" \
--volume "${RUNNER_TEMP}:${RUNNER_TEMP}" \ --workdir "$GITHUB_WORKSPACE" \
--workdir /source \
"$RENDEZVOUS_RELEASE_BUILDER" \ "$RENDEZVOUS_RELEASE_BUILDER" \
bash -c 'python3 eng/release_artifacts.py normalize-container-sbom \ bash -c 'python3 eng/release_artifacts.py normalize-container-sbom \
--file "$1/FinalFactory.Rendezvous.Container.$2.spdx.json" \ --file "$1/FinalFactory.Rendezvous.Container.$2.spdx.json" \
@@ -199,7 +262,13 @@ jobs:
_ "$RENDEZVOUS_RELEASE_DIR" "$RENDEZVOUS_VERSION" "$GITHUB_SHA" "$source_date_epoch" _ "$RENDEZVOUS_RELEASE_DIR" "$RENDEZVOUS_VERSION" "$GITHUB_SHA" "$source_date_epoch"
- name: Preserve verified candidate artifacts - 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: with:
name: rendezvous-${{ env.RENDEZVOUS_VERSION }} name: rendezvous-${{ env.RENDEZVOUS_VERSION }}
path: ${{ env.RENDEZVOUS_RELEASE_DIR }} path: ${{ env.RENDEZVOUS_RELEASE_DIR }}
@@ -207,9 +276,29 @@ jobs:
retention-days: 30 retention-days: 30
- name: Install pinned signing client - name: Install pinned signing client
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 shell: bash
with: run: |
cosign-release: v3.0.6 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 - name: Publish once, sign, attest, and create release
shell: bash shell: bash
@@ -218,4 +307,5 @@ jobs:
RENDEZVOUS_RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }} RENDEZVOUS_RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }} COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }} COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
RENDEZVOUS_WORKSPACE_SOURCE: ${{ env.RENDEZVOUS_WORKSPACE_SOURCE }}
run: ./scripts/publish-release.sh "$RENDEZVOUS_VERSION" "$RENDEZVOUS_RELEASE_DIR" run: ./scripts/publish-release.sh "$RENDEZVOUS_VERSION" "$RENDEZVOUS_RELEASE_DIR"
+1
View File
@@ -8,6 +8,7 @@ TestResults/
*.userosscache *.userosscache
deploy/compose/.smoke.env deploy/compose/.smoke.env
artifacts/ artifacts/
.release-work/
__pycache__/ __pycache__/
*.pyc *.pyc
deploy/compose/secrets/* deploy/compose/secrets/*
+1 -1
View File
@@ -4,7 +4,7 @@ All notable Rendezvous release changes are recorded here. Versions follow
Semantic Versioning; HTTP, UDP, and connection-ticket format compatibility is Semantic Versioning; HTTP, UDP, and connection-ticket format compatibility is
tracked separately and called out for every release. tracked separately and called out for every release.
## 1.0.0 - 2026-07-16 ## 1.0.0 - 2026-08-22
### Compatibility ### Compatibility
+1 -1
View File
@@ -21,7 +21,7 @@ RUN dotnet publish src/FinalFactory.Rendezvous.Server/FinalFactory.Rendezvous.Se
/p:RepositoryCommit="$SOURCE_REVISION_ID" \ /p:RepositoryCommit="$SOURCE_REVISION_ID" \
/p:SourceRevisionId="$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 \ ENV ASPNETCORE_HTTP_PORTS=8080 \
DOTNET_EnableDiagnostics=0 \ DOTNET_EnableDiagnostics=0 \
+4 -1
View File
@@ -30,7 +30,10 @@ def load_json(path: pathlib.Path):
def dependency_inventory(root: pathlib.Path): def dependency_inventory(root: pathlib.Path):
dependencies = {} dependencies = {}
for lock_path in sorted(root.glob("**/packages.lock.json")): 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 continue
lock = load_json(lock_path) lock = load_json(lock_path)
for framework in lock.get("dependencies", {}).values(): for framework in lock.get("dependencies", {}).values():
+28 -3
View File
@@ -10,6 +10,22 @@ image="$registry/heikyu/rendezvous:$version"
token="${RENDEZVOUS_RELEASE_TOKEN:?RENDEZVOUS_RELEASE_TOKEN is required}" token="${RENDEZVOUS_RELEASE_TOKEN:?RENDEZVOUS_RELEASE_TOKEN is required}"
username="${RENDEZVOUS_RELEASE_USERNAME:?RENDEZVOUS_RELEASE_USERNAME is required}" username="${RENDEZVOUS_RELEASE_USERNAME:?RENDEZVOUS_RELEASE_USERNAME is required}"
release_builder="${RENDEZVOUS_RELEASE_BUILDER:?RENDEZVOUS_RELEASE_BUILDER 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 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}"
[[ -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)" docker_config="$(mktemp -d)"
curl_config="$(mktemp)" curl_config="$(mktemp)"
release_request="" release_request=""
@@ -33,12 +49,21 @@ for command in cosign curl docker dotnet jq; do
} }
done done
# Publication is a one-way gate: Gitea package versions are immutable, so the
# signing material must be proven usable before anything is pushed. Never print
# the key or the probe output.
: "${COSIGN_PRIVATE_KEY:?COSIGN_PRIVATE_KEY is required}"
: "${COSIGN_PASSWORD:?COSIGN_PASSWORD is required}"
cosign public-key --key env://COSIGN_PRIVATE_KEY >/dev/null || {
echo "Configured cosign key could not be loaded; refusing to publish." >&2
exit 1
}
run_release_builder() { run_release_builder() {
docker run --rm \ docker run --rm \
--user "$(id -u):$(id -g)" \ --user "$(id -u):$(id -g)" \
--volume "$root:/source:ro" \ --volume "$workspace_source:$root" \
--volume "$release_dir:$release_dir" \ --workdir "$root" \
--workdir /source \
"$release_builder" "$@" "$release_builder" "$@"
} }
+20 -1
View File
@@ -5,6 +5,11 @@ root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
version="${1:?usage: verify-real-consumers.sh VERSION RELEASE_DIRECTORY}" version="${1:?usage: verify-real-consumers.sh VERSION RELEASE_DIRECTORY}"
release_dir="${2:?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" manifest="$root/eng/consumer-revisions.json"
# The pinned consumers live in private repositories, so release automation must
# authenticate. Keep the token in the environment git reads config from: it must
# never reach argv, the remote URL, or on-disk repository configuration.
token="${RENDEZVOUS_CONSUMER_TOKEN:-${RENDEZVOUS_RELEASE_TOKEN:-}}"
export GIT_TERMINAL_PROMPT=0
work="$(mktemp -d "${TMPDIR:-/tmp}/rendezvous-consumers.XXXXXX")" work="$(mktemp -d "${TMPDIR:-/tmp}/rendezvous-consumers.XXXXXX")"
cleanup() { cleanup() {
rm -rf "$work" rm -rf "$work"
@@ -18,6 +23,20 @@ for command in dotnet git jq python3; do
} }
done done
fetch_consumer_revision() {
local checkout="$1"
local repository="$2"
local revision="$3"
if [[ -z "$token" ]]; then
git -C "$checkout" fetch --quiet --depth 1 origin "$revision"
return
fi
GIT_CONFIG_COUNT=1 \
GIT_CONFIG_KEY_0="http.${repository}.extraHeader" \
GIT_CONFIG_VALUE_0="Authorization: token ${token}" \
git -C "$checkout" fetch --quiet --depth 1 origin "$revision"
}
python3 "$root/eng/release_artifacts.py" consumer-config \ python3 "$root/eng/release_artifacts.py" consumer-config \
--local-source "$release_dir" \ --local-source "$release_dir" \
--output "$work/NuGet.config" --output "$work/NuGet.config"
@@ -31,7 +50,7 @@ for ((index = 0; index < count; index++)); do
checkout="$work/$name" checkout="$work/$name"
git -c init.defaultBranch=main init --quiet "$checkout" git -c init.defaultBranch=main init --quiet "$checkout"
git -C "$checkout" remote add origin "$repository" git -C "$checkout" remote add origin "$repository"
git -C "$checkout" fetch --quiet --depth 1 origin "$revision" fetch_consumer_revision "$checkout" "$repository" "$revision"
GIT_LFS_SKIP_SMUDGE=1 git -C "$checkout" checkout --quiet --detach FETCH_HEAD GIT_LFS_SKIP_SMUDGE=1 git -C "$checkout" checkout --quiet --detach FETCH_HEAD
[[ "$(git -C "$checkout" rev-parse HEAD)" == "$revision" ]] || { [[ "$(git -C "$checkout" rev-parse HEAD)" == "$revision" ]] || {
echo "$name did not resolve the pinned consumer revision." >&2 echo "$name did not resolve the pinned consumer revision." >&2
@@ -1,6 +1,7 @@
using System.Diagnostics; using System.Diagnostics;
using System.Net; using System.Net;
using System.Net.Sockets; using System.Net.Sockets;
using System.Runtime.CompilerServices;
using FinalFactory.Rendezvous.Client; using FinalFactory.Rendezvous.Client;
using FinalFactory.Rendezvous.Contracts; using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.Abuse; using FinalFactory.Rendezvous.Server.Abuse;
@@ -146,13 +147,19 @@ public sealed class RendezvousClientIntegrationTests
public async Task BrowserStreamResetsInvalidCursorReplaysReconnectAndReleasesConnections() public async Task BrowserStreamResetsInvalidCursorReplaysReconnectAndReleasesConnections()
{ {
await using ClientTestHost host = await ClientTestHost.StartAsync(); await using ClientTestHost host = await ClientTestHost.StartAsync();
RendezvousPublisherClient publisher = new(host.HttpClient); // Budgets are sized for the release pipeline, not for a developer machine. That job
RendezvousSessionBrowserClient browser = new( // runs this suite inside a builder container on a busy act_runner host, alongside
host.HttpClient, // parallel image builds, so the in-process Kestrel host and the HttpClient driving it
new RendezvousClientOptions // 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
RequestTimeout = TimeSpan.FromSeconds(15), // 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( PublishedSession session = AssertSuccess(await publisher.RegisterAsync(
CreateRegistration(200), CreateRegistration(200),
host.PublisherCredential)); host.PublisherCredential));
@@ -161,18 +168,22 @@ public sealed class RendezvousClientIntegrationTests
BrowseSessionsResponse snapshot = AssertSuccess(await browser.BrowseAsync(request)); BrowseSessionsResponse snapshot = AssertSuccess(await browser.BrowseAsync(request));
Assert.False(string.IsNullOrWhiteSpace(snapshot.StreamCursor)); Assert.False(string.IsNullOrWhiteSpace(snapshot.StreamCursor));
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(30)); using CancellationTokenSource timeout = new(TimeSpan.FromMinutes(2));
await using (IAsyncEnumerator<RendezvousClientResult<SessionStreamEvent>> invalid = browser await using (IAsyncEnumerator<RendezvousClientResult<SessionStreamEvent>> invalid =
.StreamAsync(request, CorruptCursor(snapshot.StreamCursor), timeout.Token) StreamWithOpenRetry(
browser,
request,
CorruptCursor(snapshot.StreamCursor),
timeout.Token)
.GetAsyncEnumerator(timeout.Token)) .GetAsyncEnumerator(timeout.Token))
{ {
Assert.True(await invalid.MoveNextAsync()); Assert.True(await invalid.MoveNextAsync());
Assert.Equal(SessionStreamEventKind.Reset, AssertSuccess(invalid.Current).Kind); Assert.Equal(SessionStreamEventKind.Reset, AssertSuccess(invalid.Current).Kind);
Assert.False(await invalid.MoveNextAsync()); Assert.False(await invalid.MoveNextAsync());
} }
await using IAsyncEnumerator<RendezvousClientResult<SessionStreamEvent>> events = browser await using IAsyncEnumerator<RendezvousClientResult<SessionStreamEvent>> events =
.StreamAsync(request, snapshot.StreamCursor, timeout.Token) StreamWithOpenRetry(browser, request, snapshot.StreamCursor, timeout.Token)
.GetAsyncEnumerator(timeout.Token); .GetAsyncEnumerator(timeout.Token);
Task<bool> upsertPending = events.MoveNextAsync().AsTask(); Task<bool> upsertPending = events.MoveNextAsync().AsTask();
Assert.True((await publisher.UpdateAsync( Assert.True((await publisher.UpdateAsync(
session, session,
@@ -190,8 +201,8 @@ public sealed class RendezvousClientIntegrationTests
Assert.Equal(SessionStreamEventKind.SessionUpsert, upsert.Kind); Assert.Equal(SessionStreamEventKind.SessionUpsert, upsert.Kind);
Assert.Equal("Live update", upsert.Session!.DisplayName); Assert.Equal("Live update", upsert.Session!.DisplayName);
await using (IAsyncEnumerator<RendezvousClientResult<SessionStreamEvent>> replay = browser await using (IAsyncEnumerator<RendezvousClientResult<SessionStreamEvent>> replay =
.StreamAsync(request, snapshot.StreamCursor, timeout.Token) StreamWithOpenRetry(browser, request, snapshot.StreamCursor, timeout.Token)
.GetAsyncEnumerator(timeout.Token)) .GetAsyncEnumerator(timeout.Token))
{ {
Assert.True(await replay.MoveNextAsync()); Assert.True(await replay.MoveNextAsync());
@@ -212,6 +223,68 @@ public sealed class RendezvousClientIntegrationTests
Assert.Equal(session.ListingId, remove.ListingId); 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) private static string CorruptCursor(string cursor)
{ {
char replacement = cursor[^1] == 'a' ? 'b' : 'a'; char replacement = cursor[^1] == 'a' ? 'b' : 'a';
@@ -86,8 +86,50 @@ public sealed class ReleaseCompatibilityTests
Assert.DoesNotContain("skip-duplicate", workflow, StringComparison.OrdinalIgnoreCase); Assert.DoesNotContain("skip-duplicate", workflow, StringComparison.OrdinalIgnoreCase);
Assert.Contains("check-compatibility.sh", workflow, StringComparison.Ordinal); Assert.Contains("check-compatibility.sh", workflow, StringComparison.Ordinal);
Assert.Contains("--platform linux/amd64", workflow, StringComparison.Ordinal); Assert.Contains("--platform linux/amd64", workflow, StringComparison.Ordinal);
Assert.Contains("ignore-unfixed: false", workflow, StringComparison.Ordinal); Assert.DoesNotContain("aquasecurity/trivy-action", workflow, StringComparison.Ordinal);
Assert.Contains("format: spdx-json", 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("normalize-container-sbom", workflow, StringComparison.Ordinal);
Assert.Contains("finalize-release-candidate.sh", workflow, StringComparison.Ordinal); Assert.Contains("finalize-release-candidate.sh", workflow, StringComparison.Ordinal);
Assert.Contains("verify-real-consumers.sh", workflow, StringComparison.Ordinal); Assert.Contains("verify-real-consumers.sh", workflow, StringComparison.Ordinal);