Files
Rendezvous/scripts/build-release.sh
T
KyuubiYoru cc5793f935
quality-gate / quality (push) Failing after 1m50s
quality-gate / container (push) Has been skipped
feat(release): add reproducible signed artifacts (#19)
2026-07-16 17:48:21 +02:00

254 lines
8.7 KiB
Bash
Executable File

#!/usr/bin/env bash
set -euo pipefail
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
version="${1:-}"
output="${2:-}"
property() {
sed -n "s:.*<$1>\(.*\)</$1>.*:\1:p" "$root/eng/Versions.props"
}
if [[ -z "$version" ]]; then
version="$(property RendezvousVersion)"
fi
semver='^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-([0-9A-Za-z-]+\.)*[0-9A-Za-z-]+)?(\+([0-9A-Za-z-]+\.)*[0-9A-Za-z-]+)?$'
if [[ ! "$version" =~ $semver ]]; then
echo "Release version is not valid SemVer: $version" >&2
exit 1
fi
prerelease="${version%%+*}"
if [[ "$prerelease" == *-* ]]; then
prerelease="${prerelease#*-}"
IFS='.' read -r -a prerelease_identifiers <<<"$prerelease"
for identifier in "${prerelease_identifiers[@]}"; do
if [[ "$identifier" =~ ^[0-9]+$ && ! "$identifier" =~ ^(0|[1-9][0-9]*)$ ]]; then
echo "Numeric prerelease identifiers must not contain leading zeroes: $version" >&2
exit 1
fi
done
fi
if [[ "$version" != "$(property RendezvousVersion)" ]]; then
echo "Requested version $version differs from eng/Versions.props." >&2
exit 1
fi
for command in dotnet git python3 tar gzip sha256sum cmp jq; do
command -v "$command" >/dev/null || {
echo "Required release command is unavailable: $command" >&2
exit 1
}
done
commit="$(git -C "$root" rev-parse HEAD)"
source_date_epoch="$(git -C "$root" show -s --format=%ct "$commit")"
tree_state=clean
if [[ -n "$(git -C "$root" status --porcelain --untracked-files=normal)" ]]; then
tree_state=dirty
fi
allow_dirty=()
if [[ "$tree_state" != clean ]]; then
if [[ "${RENDEZVOUS_RELEASE_ALLOW_DIRTY:-0}" != 1 ]]; then
echo "A formal release must be built from a clean Git tree." >&2
exit 1
fi
allow_dirty=(--allow-dirty)
fi
if [[ -z "$output" ]]; then
output="$root/artifacts/release/$version"
fi
if [[ -e "$output" ]]; then
echo "Release output already exists; refusing to overwrite: $output" >&2
exit 1
fi
mkdir -p "$(dirname "$output")"
output="$(cd "$(dirname "$output")" && pwd)/$(basename "$output")"
work="$(mktemp -d "${TMPDIR:-/tmp}/rendezvous-release.XXXXXX")"
cleanup() {
rm -rf "$work"
}
trap cleanup EXIT
mkdir -p "$work/pack-1" "$work/pack-2" "$work/publish-1" "$work/publish-2" "$output"
create_server_archive() {
local publish_directory="$1"
local destination="$2"
tar --sort=name \
--mtime="@$source_date_epoch" \
--owner=0 --group=0 --numeric-owner \
-C "$publish_directory" -cf - . \
| gzip -n >"$destination"
}
common=(
-p:ContinuousIntegrationBuild=true
-p:PackageVersion="$version"
-p:RepositoryCommit="$commit"
-p:SourceRevisionId="$commit"
)
cd "$root"
dotnet restore Rendezvous.slnx --locked-mode
python3 eng/release_artifacts.py policy --root "$root" >"$work/dependency-policy.json"
dotnet package list \
--project Rendezvous.slnx \
--vulnerable \
--include-transitive \
--no-restore \
--format json >"$work/nuget-vulnerabilities.json"
python3 eng/release_artifacts.py audit --input "$work/nuget-vulnerabilities.json"
dotnet format Rendezvous.slnx --verify-no-changes --no-restore
api_before="$(sha256sum docs/api/*.json)"
dotnet build Rendezvous.slnx --configuration Release --no-restore "${common[@]}"
cp src/FinalFactory.Rendezvous.Server/bin/Release/net10.0/FinalFactory.Rendezvous.Server.dll \
"$work/FinalFactory.Rendezvous.Server.first.dll"
cp src/FinalFactory.Rendezvous.Server/bin/Release/net10.0/FinalFactory.Rendezvous.Server.pdb \
"$work/FinalFactory.Rendezvous.Server.first.pdb"
dotnet publish src/FinalFactory.Rendezvous.Server/FinalFactory.Rendezvous.Server.csproj \
--configuration Release \
--no-build \
--no-restore \
--output "$work/publish-1" \
-p:UseAppHost=false \
-p:OpenApiGenerateDocuments=false \
"${common[@]}"
create_server_archive "$work/publish-1" "$work/FinalFactory.Rendezvous.Server.first.tar.gz"
if [[ "$tree_state" == clean ]]; then
git diff --exit-code -- docs/api
elif [[ "$api_before" != "$(sha256sum docs/api/*.json)" ]]; then
echo "Generated OpenAPI changed during the release build." >&2
exit 1
fi
dotnet test Rendezvous.slnx --configuration Release --no-build
for project in Client Contracts; do
dotnet pack "src/FinalFactory.Rendezvous.$project/FinalFactory.Rendezvous.$project.csproj" \
--configuration Release --no-build --output "$work/pack-1" "${common[@]}"
done
for package in "$work/pack-1"/*; do
python3 eng/release_artifacts.py normalize-package \
--package "$package" \
--source-date-epoch "$source_date_epoch" \
--version "$version"
done
# Rebuild from the locked graph and prove package byte reproducibility.
dotnet clean Rendezvous.slnx --configuration Release >/dev/null
dotnet restore Rendezvous.slnx --locked-mode
dotnet build Rendezvous.slnx --configuration Release --no-restore "${common[@]}"
for project in Client Contracts; do
python3 eng/release_artifacts.py source-link \
--file "src/FinalFactory.Rendezvous.$project/obj/Release/netstandard2.1/FinalFactory.Rendezvous.$project.sourcelink.json" \
--commit "$commit"
done
cmp --silent \
"$work/FinalFactory.Rendezvous.Server.first.dll" \
src/FinalFactory.Rendezvous.Server/bin/Release/net10.0/FinalFactory.Rendezvous.Server.dll || {
echo "Server assembly is not byte reproducible." >&2
exit 1
}
cmp --silent \
"$work/FinalFactory.Rendezvous.Server.first.pdb" \
src/FinalFactory.Rendezvous.Server/bin/Release/net10.0/FinalFactory.Rendezvous.Server.pdb || {
echo "Server portable PDB is not byte reproducible." >&2
exit 1
}
for project in Client Contracts; do
dotnet pack "src/FinalFactory.Rendezvous.$project/FinalFactory.Rendezvous.$project.csproj" \
--configuration Release --no-build --output "$work/pack-2" "${common[@]}"
done
for package in "$work/pack-2"/*; do
python3 eng/release_artifacts.py normalize-package \
--package "$package" \
--source-date-epoch "$source_date_epoch" \
--version "$version"
done
for package in "$work/pack-1"/*; do
cmp --silent "$package" "$work/pack-2/$(basename "$package")" || {
echo "Package is not byte reproducible: $(basename "$package")" >&2
exit 1
}
done
cp "$work/pack-1"/* "$output/"
python3 eng/release_artifacts.py consumer-config \
--local-source "$output" \
--output "$work/consumer.NuGet.config"
for consumer in spacegame unscouted; do
project="$root/tests/consumers/$consumer/$(find "$root/tests/consumers/$consumer" -maxdepth 1 -name '*.csproj' -printf '%f\n')"
packages="$work/consumer-packages-$consumer"
dotnet restore "$project" \
-p:RendezvousPackageVersion="$version" \
-p:RestoreLockedMode=false \
--packages "$packages" \
--configfile "$work/consumer.NuGet.config" \
--force-evaluate
dotnet build "$project" \
--configuration Release --no-restore \
-p:RendezvousPackageVersion="$version"
assets="$(dirname "$project")/obj/project.assets.json"
python3 eng/release_artifacts.py consumer --assets "$assets" --version "$version"
done
dotnet publish src/FinalFactory.Rendezvous.Server/FinalFactory.Rendezvous.Server.csproj \
--configuration Release \
--no-build \
--no-restore \
--output "$work/publish-2" \
-p:UseAppHost=false \
-p:OpenApiGenerateDocuments=false \
"${common[@]}"
server_archive="$output/FinalFactory.Rendezvous.Server.$version.linux-x64.tar.gz"
create_server_archive "$work/publish-2" "$server_archive"
cmp --silent "$work/FinalFactory.Rendezvous.Server.first.tar.gz" "$server_archive" || {
echo "Server archive is not byte reproducible." >&2
exit 1
}
cp CHANGELOG.md "$output/CHANGELOG.md"
python3 eng/release_artifacts.py sbom \
--root "$root" \
--version "$version" \
--commit "$commit" \
--source-date-epoch "$source_date_epoch" \
--server-deps "$work/publish-2/FinalFactory.Rendezvous.Server.deps.json" \
--output "$output/FinalFactory.Rendezvous.$version.spdx.json"
provenance=(
provenance
--root "$root"
--version "$version"
--commit "$commit"
--tree-state "$tree_state"
--source-date-epoch "$source_date_epoch"
--dotnet-sdk "$(dotnet --version)"
--build-tool "python=$(python3 --version 2>&1)"
--build-tool "tar=$(tar --version | sed -n '1p')"
--build-tool "gzip=$(gzip --version | sed -n '1p')"
--build-tool "jq=$(jq --version)"
--output "$output/release-provenance.json"
)
while IFS= read -r base; do
provenance+=(--base-digest "$base")
done < <(sed -n 's/^FROM \([^ ]*\).*/\1/p' Dockerfile)
while IFS= read -r base; do
provenance+=(--builder-base "$base")
done < <(sed -n 's/^FROM \([^ ]*\).*/\1/p' eng/release-builder.Dockerfile)
python3 eng/release_artifacts.py "${provenance[@]}"
(
cd "$output"
find . -maxdepth 1 -type f ! -name checksums.sha256 -printf '%f\n' \
| LC_ALL=C sort \
| xargs sha256sum >checksums.sha256
)
python3 eng/release_artifacts.py verify \
--root "$root" \
--release-dir "$output" \
--version "$version" \
"${allow_dirty[@]}"
echo "Release artifacts verified at $output"