#!/usr/bin/env bash # build-release.sh — the one committed, repeatable tidalDB release path. # # Replaces the ad-hoc hand-typed cross-compile + buildx + push with a single # command on a clean checkout: # # ./scripts/build-release.sh [server|dr|stress|all] # # It HOST cross-compiles (mac-arm64 -> x86_64-unknown-linux-gnu, glibc 2.41 via the # homebrew toolchain), packages the binaries into the committed docker/release/* # Dockerfiles via the amd64 buildx builder, pushes, and prints the resulting # @sha256 digest for each image (pin that digest in k8s/cluster/statefulset.yaml). # # The project bans CI/CD pipelines (orchard9-k3sf/CLAUDE.md) — this is a SCRIPT, # run manually or from an operator's shell, never a pipeline. Registry creds come # from the Docker daemon's existing login (run `docker login registry.threesix.ai` # first); NO secret is embedded here. set -euo pipefail TAG="${1:-}" COMPONENT="${2:-all}" REGISTRY="${TIDAL_REGISTRY:-registry.threesix.ai/tidal}" TARGET="x86_64-unknown-linux-gnu" BUILDER="${TIDAL_BUILDX_BUILDER:-amd64builder}" die() { echo "build-release: $*" >&2; exit 1; } [ -n "$TAG" ] || die "usage: build-release.sh [server|dr|stress|all]" [ "$TAG" != "latest" ] || die "refusing mutable release tag 'latest'" [[ "$TAG" =~ ^[a-z0-9][a-z0-9._-]{0,127}$ ]] \ || die "tag must match ^[a-z0-9][a-z0-9._-]{0,127}$" case "$COMPONENT" in server|dr|stress|all) ;; *) die "component must be server|dr|stress|all" ;; esac # ── Toolchain preflight (fail fast, no half-built image) ───────────────────── command -v docker >/dev/null || die "docker not on PATH" command -v git >/dev/null || die "git not on PATH" command -v jq >/dev/null || die "jq not on PATH" if [ "$COMPONENT" != "stress" ]; then command -v cargo >/dev/null || die "cargo not on PATH" command -v "${TARGET}-gcc" >/dev/null \ || die "missing ${TARGET}-gcc (brew install ${TARGET})" PROTOC_BIN="${PROTOC:-$(command -v protoc || true)}" [ -n "$PROTOC_BIN" ] || die "protoc not found (brew install protobuf), or set PROTOC" rustup target list --installed 2>/dev/null | grep -qx "$TARGET" \ || die "rust target $TARGET not installed (rustup target add $TARGET)" fi REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" cd "$REPO_ROOT" # Every image must have one reproducible source identity. Clean releases use # HEAD. An explicitly allowed canary hashes HEAD, both tracked diffs, and every # untracked non-ignored file (path + Git blob hash), so two distinct source # trees cannot silently share the same `-dirty` identity. This check also covers # stress: its Docker build consumes the repository source directly. UNTRACKED="$(git ls-files --others --exclude-standard)" if git diff --quiet && git diff --cached --quiet && [ -z "$UNTRACKED" ]; then export GIT_HASH="${TIDAL_BUILD_HASH:-$(git rev-parse --verify HEAD)}" else [ "${TIDAL_ALLOW_DIRTY:-0}" = "1" ] \ || die "source changes present; commit them or set TIDAL_ALLOW_DIRTY=1 for an explicit canary" DIRTY_HASH="$( { git rev-parse --verify HEAD git diff --binary git diff --cached --binary git ls-files --others --exclude-standard -z | while IFS= read -r -d '' file; do printf 'untracked\0%s\0' "$file" git hash-object -- "$file" done } | shasum -a 256 | cut -c1-16 )" export GIT_HASH="${TIDAL_BUILD_HASH:-${DIRTY_HASH}-dirty}" fi echo "==> source identity $GIT_HASH" # ── Pinned cross-compile environment (the documented recipe) ───────────────── export CC_x86_64_unknown_linux_gnu="${TARGET}-gcc" export CXX_x86_64_unknown_linux_gnu="${TARGET}-g++" export AR_x86_64_unknown_linux_gnu="${TARGET}-ar" export CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER="${TARGET}-gcc" export PROTOC="${PROTOC_BIN:-}" # Which crates to cross-compile for the selected component(s). PKGS=() case "$COMPONENT" in server) PKGS=(-p tidal-server) ;; dr) PKGS=(-p tidalctl -p tidal-server) ;; stress) PKGS=() ;; # stress builds IN-container (pure Rust, no engine deps) all) PKGS=(-p tidal-server -p tidalctl) ;; esac if [ "${#PKGS[@]}" -gt 0 ]; then echo "==> cross-compiling ${PKGS[*]} for $TARGET (release)" cargo build "${PKGS[@]}" --release --target "$TARGET" --locked fi # ── buildx amd64 builder (idempotent) ──────────────────────────────────────── if ! docker buildx inspect "$BUILDER" >/dev/null 2>&1; then echo "==> creating buildx builder $BUILDER" docker buildx create --name "$BUILDER" --driver docker-container >/dev/null fi BIN_DIR="target/$TARGET/release" STAGE="$(mktemp -d)" trap 'rm -rf "$STAGE"' EXIT # Build one image from a staged context; print its pushed digest. build_image() { # $1=image_name $2=dockerfile $3=stage_subdir local image="$REGISTRY/$1:$TAG" dockerfile="$2" ctx="$STAGE/$3" echo "==> building $image" docker buildx build --builder "$BUILDER" --platform linux/amd64 \ -f "$dockerfile" -t "$image" --push "$ctx" local digest digest="$(docker buildx imagetools inspect "$image" --format '{{json .Manifest}}' | jq -er '.digest')" [ -n "$digest" ] || die "registry returned no digest for $image" echo "RELEASE $1: $image@$digest" } if [ "$COMPONENT" = server ] || [ "$COMPONENT" = all ]; then mkdir -p "$STAGE/server" cp "$BIN_DIR/tidal-server" "$STAGE/server/" cp -a docker/release/config "$STAGE/server/config" cp docker/release/Dockerfile "$STAGE/server/Dockerfile" build_image server "$STAGE/server/Dockerfile" server fi if [ "$COMPONENT" = dr ] || [ "$COMPONENT" = all ]; then mkdir -p "$STAGE/dr" cp "$BIN_DIR/tidalctl" "$BIN_DIR/tidal-server" "$STAGE/dr/" cp docker/release/dr.Dockerfile "$STAGE/dr/Dockerfile" build_image tidalctl "$STAGE/dr/Dockerfile" dr fi if [ "$COMPONENT" = stress ] || [ "$COMPONENT" = all ]; then # Pure-Rust, in-container build (includes soak-eval + soak-watch). Context = # repo root so workspace manifests resolve; the .dockerignore prunes it. The # OCI revision label makes the source identity inspectable after the build. echo "==> building $REGISTRY/stress:$TAG (in-container, includes soak-eval + soak-watch)" docker buildx build --builder "$BUILDER" --platform linux/amd64 \ --build-arg "GIT_HASH=$GIT_HASH" \ -f docker/stress/Dockerfile -t "$REGISTRY/stress:$TAG" --push . d="$(docker buildx imagetools inspect "$REGISTRY/stress:$TAG" --format '{{json .Manifest}}' | jq -er '.digest')" [ -n "$d" ] || die "registry returned no digest for $REGISTRY/stress:$TAG" echo "RELEASE stress: $REGISTRY/stress:$TAG@$d" fi echo "==> done. Deploy only the printed tag@sha256 references."