#!/usr/bin/env bash
#
# Build a Steid release artefact: a tarball that extracts to a self-contained
# directory containing the binary, the asset bundle beside it, and a README.
#
#     steid-<version>-<target>/
#       steid
#       assets/          <- manifest.toml + content-hashed CSS
#       README.md
#
# Why a plain binary and not a container: Steid is meant to be installable on a
# £4 VPS by someone who does not run Docker. The container image still exists
# (see ./Dockerfile) and this script uses Docker as a *build* tool, but nothing
# in the shipped artefact depends on it.
#
# THE BUILD COMMAND MATTERS. `cargo build --release` alone produces a binary
# that will not boot: `main` calls `AssetBundle::load()`, which walks up from the
# executable looking for `assets/manifest.toml`, and `build.rs` never writes one.
# `topcoat asset bundle --release` runs `cargo build --release` itself, then
# scans the linked binary for the assets it declares and writes them plus the
# manifest to `target/assets`. That is the only supported way to build Steid.
#
# ON MACOS you cannot produce a Linux artefact with the host toolchain — there is
# no linker for it and `build.rs` runs a platform-specific Tailwind binary. This
# script therefore builds inside a container of the target platform by default
# (Docker, with qemu emulation when the arch differs from the host). `--native`
# skips all that and builds with the local toolchain, which is what you want for
# a quick smoke test of the artefact layout, not for a release.
#
# Usage:
#   ./release.sh                                  # default target, via Docker
#   ./release.sh --target aarch64-unknown-linux-gnu
#   ./release.sh --all          # every target a published release carries
#   ./release.sh --native                         # host target, local toolchain
#   ./release.sh --target x86_64-unknown-linux-gnu --version 0.1.0
#
set -euo pipefail

# --- parameters -------------------------------------------------------------

# glibc, not musl — measured, then decided.
#
# musl was tried and failed on `ring`: Debian's `musl-gcc` wrapper rejects the
# `-m64` that cc-rs passes, so it would need a real cross toolchain rather than
# `musl-tools`. But the decisive argument is not that it was awkward. **musl buys
# a binary with no runtime dependencies, and Steid hard-requires `git` on PATH** —
# anyone installing this already has a package manager and a distro, so the
# portability is unusable. The musl path below still works if a cross toolchain
# ever makes it worthwhile; nothing else in the script cares which is chosen.
TARGET="${STEID_RELEASE_TARGET:-x86_64-unknown-linux-gnu}"

# Bullseye pins the glibc floor at 2.31, which covers Debian 11+ and Ubuntu
# 20.04+. Building on bookworm would need 2.36 and silently exclude Ubuntu 22.04,
# which is still everywhere — and the failure lands on the user as
# `GLIBC_2.36 not found`, at startup, with nothing pointing at the build.
#
# Pinned to 1.97 for the same reason the Dockerfile pins it: rustc >= 1.95 is a
# hard floor for Topcoat 0.5, and on an older toolchain `topcoat` silently
# resolves to an empty `v0.0.0` placeholder instead of failing.
RUST_IMAGE="${STEID_RUST_IMAGE:-rust:1.97-slim-bullseye}"
TOPCOAT_CLI_VERSION="${STEID_TOPCOAT_CLI_VERSION:-0.5.0}"

VERSION=""
OUT_DIR="dist"
NATIVE=0
ALL=0

# Every target a published release carries. `install.sh` picks between them from
# `uname -m`, so this list and its architecture detection have to agree: adding a
# target here without teaching the installer about it produces a tarball nobody
# ever downloads.
ALL_TARGETS="x86_64-unknown-linux-gnu aarch64-unknown-linux-gnu"

usage() {
    sed -n '2,32p' "$0" | sed 's/^#\{1,2\} \{0,1\}//'
    exit "${1:-0}"
}

while [ $# -gt 0 ]; do
    case "$1" in
        --target)  TARGET="${2:?--target needs a triple}"; shift 2 ;;
        --version) VERSION="${2:?--version needs a value}"; shift 2 ;;
        --out)     OUT_DIR="${2:?--out needs a directory}"; shift 2 ;;
        --native)  NATIVE=1; shift ;;
        --all)     ALL=1; shift ;;
        -h|--help) usage 0 ;;
        *) echo "release.sh: unknown argument: $1" >&2; usage 1 ;;
    esac
done

REPO_ROOT="$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd)"
cd "$REPO_ROOT"

# --all re-runs this script once per target rather than looping inline, so a single
# failure aborts the whole release instead of leaving a half-published set where some
# architectures have today's build and others have last week's.
if [ "$ALL" = 1 ]; then
    for t in $ALL_TARGETS; do
        "$0" --target "$t" ${VERSION:+--version "$VERSION"} --out "$OUT_DIR" || exit 1
    done
    echo
    echo "release.sh: built every target"
    find "$OUT_DIR" -maxdepth 1 -name '*.tar.gz' -exec echo '  {}' \;
    exit 0
fi

# The version is the crate version unless overridden. Read with grep rather than
# a TOML parser so this script has no dependencies of its own.
if [ -z "$VERSION" ]; then
    VERSION="$(grep -m1 '^version *= *"' Cargo.toml | sed 's/.*"\(.*\)".*/\1/')"
fi
[ -n "$VERSION" ] || { echo "release.sh: could not determine version" >&2; exit 1; }

if [ "$NATIVE" = 1 ]; then
    TARGET="$(rustc -vV | sed -n 's/^host: //p')"
fi

NAME="steid-${VERSION}-${TARGET}"
STAGE="${OUT_DIR}/${NAME}"

echo "release.sh: building ${NAME}"

# --- build ------------------------------------------------------------------

rm -rf "$STAGE"
mkdir -p "$STAGE"

if [ "$NATIVE" = 1 ]; then
    command -v topcoat >/dev/null 2>&1 || {
        echo "release.sh: topcoat CLI not found." >&2
        echo "  cargo install topcoat-cli --version ${TOPCOAT_CLI_VERSION} --locked" >&2
        exit 1
    }
    echo "release.sh: local build (host toolchain) — NOT a release artefact"
    topcoat asset bundle --release
    cp target/release/steid "$STAGE/steid"
    cp -R target/assets "$STAGE/assets"
else
    command -v docker >/dev/null 2>&1 || {
        echo "release.sh: docker not found, and a Linux artefact cannot be built" >&2
        echo "  with the host toolchain. Install Docker, or run on Linux with" >&2
        echo "  --native, or use a cross toolchain." >&2
        exit 1
    }

    case "$TARGET" in
        x86_64-*)  PLATFORM="linux/amd64" ;;
        aarch64-*) PLATFORM="linux/arm64" ;;
        *) echo "release.sh: don't know the Docker platform for ${TARGET}" >&2; exit 1 ;;
    esac

    # musl needs the cross-linker and the std for the target. On a glibc builder
    # image this is a cross-compile even when the arch matches the host, which is
    # the point: build.rs keeps running against glibc.
    EXTRA_SETUP=""
    BIN_PATH="target/release/steid"
    case "$TARGET" in
        *-musl)
            EXTRA_SETUP="apt-get update && apt-get install -y --no-install-recommends musl-tools && rm -rf /var/lib/apt/lists/* && rustup target add ${TARGET} && export CARGO_BUILD_TARGET=${TARGET}"
            BIN_PATH="target/${TARGET}/release/steid"
            ;;
    esac

    echo "release.sh: building in ${RUST_IMAGE} on ${PLATFORM}"

    # A throwaway image built from a heredoc rather than the repo Dockerfile: that
    # one produces a runtime *image*, this one produces files to copy out. Keeping
    # them separate means neither has to compromise for the other.
    IMAGE_TAG="steid-release-build:${VERSION}-${TARGET}"
    docker buildx build \
        --platform "$PLATFORM" \
        --load \
        --tag "$IMAGE_TAG" \
        --build-arg "TARGET=${TARGET}" \
        --file - . <<EOF
FROM ${RUST_IMAGE}
ARG TARGET
RUN cargo install topcoat-cli --version ${TOPCOAT_CLI_VERSION} --locked
WORKDIR /src
COPY . .
RUN set -eux; ${EXTRA_SETUP:-true}; \\
    topcoat asset bundle --release; \\
    mkdir -p /out; \\
    cp ${BIN_PATH} /out/steid; \\
    cp -r target/assets /out/assets
EOF

    # `docker create` + `docker cp` rather than a bind mount: the build ran on a
    # possibly-emulated platform and this needs no write access to the host tree.
    CONTAINER="$(docker create --platform "$PLATFORM" "$IMAGE_TAG" /bin/true)"
    trap 'docker rm -f "$CONTAINER" >/dev/null 2>&1 || true' EXIT
    docker cp "${CONTAINER}:/out/steid" "$STAGE/steid"
    docker cp "${CONTAINER}:/out/assets" "$STAGE/assets"
    docker rm -f "$CONTAINER" >/dev/null
    trap - EXIT
fi

chmod 755 "$STAGE/steid"

# The README ships inside the tarball so an unpacked directory on a server is
# self-explanatory without network access.
cp README.md "$STAGE/README.md"

# --- sanity checks ----------------------------------------------------------

# The single failure mode worth guarding: an artefact whose assets are missing or
# in the wrong place boots fine in CI and dies on the user's first request.
[ -f "$STAGE/assets/manifest.toml" ] || {
    echo "release.sh: assets/manifest.toml is missing — was this built with" >&2
    echo "  'topcoat asset bundle' and not a bare 'cargo build'?" >&2
    exit 1
}

# --- package ----------------------------------------------------------------

TARBALL="${OUT_DIR}/${NAME}.tar.gz"
# `--no-xattrs` and COPYFILE_DISABLE because macOS's bsdtar otherwise stores
# Apple extended attributes, and GNU tar on the machine that extracts this then
# prints a warning line per file: "Ignoring unknown extended header keyword
# 'LIBARCHIVE.xattr.com.apple.provenance'". Harmless, and it makes a release look
# broken in the first thirty seconds a stranger spends with it.
COPYFILE_DISABLE=1 tar --no-xattrs -czf "$TARBALL" -C "$OUT_DIR" "$NAME" 2>/dev/null \
    || COPYFILE_DISABLE=1 tar -czf "$TARBALL" -C "$OUT_DIR" "$NAME"

# A checksum file per tarball, which is what install.sh fetches and verifies.
# Written next to the tarball with a bare name inside it so `sha256sum -c` works
# from the download directory.
(
    cd "$OUT_DIR"
    if command -v sha256sum >/dev/null 2>&1; then
        sha256sum "${NAME}.tar.gz" > "${NAME}.tar.gz.sha256"
    else
        # macOS has shasum, not sha256sum. Same output format.
        shasum -a 256 "${NAME}.tar.gz" > "${NAME}.tar.gz.sha256"
    fi
)

echo
echo "release.sh: wrote"
echo "  ${TARBALL}"
echo "  ${TARBALL}.sha256"
echo
echo "Upload both to the release named v${VERSION}. install.sh expects exactly"
echo "these filenames."
