#!/usr/bin/env bash
# release.sh — orchestrates a Choreographr release: versioned build, release
# tarball, checksums, optional .deb/.rpm, and (only with --upload) the GitHub
# release. The default is a DRY RUN: it produces all artifacts in dist/ and
# prints the exact commands to run, but never uploads anything.
#
# Usage:
#   scripts/release.sh                 # dry-run: artifacts + instructions
#   scripts/release.sh --upload        # also run `gh release create`
#   scripts/release.sh --allow-dirty   # skip the dirty-tree guard
#
# Requires: cargo install cargo-zigbuild (the Linux musl tarball build below —
# x86_64 and arm64 — cross-compiles via zig).
#
# Dist/release binaries are built on STABLE Rust (reproducible; matches the
# crates.io/MSRV story) under the workspace's dedicated [profile.dist] profile
# (see root Cargo.toml): `--profile dist` rather than `--release` puts the
# shipped artifacts in target/<triple>/dist, separate from any local
# `cargo build --release` output, so the staging below can only ever pick up
# binaries this pipeline produced. Each cargo build goes through
# scripts/build-stable.sh, which temporarily strips the nightly-only
# profile-rustflags bits the dev toolchain uses, runs the build under
# `cargo +stable`, then restores them.
set -euo pipefail

usage() {
    cat <<EOF
Usage: $0 [--upload] [--allow-dirty] [--help]

Dry-run by default: builds, tarballs, checksums, .deb/.rpm, and prints the
exact upload + checklist commands. Never uploads unless --upload is passed.

  --upload        also run \`gh release create\` with the built artifacts
  --allow-dirty   skip the "working tree must be clean" guard
  --help          show this help
EOF
}

UPLOAD=0
ALLOW_DIRTY=0
for arg in "$@"; do
    case "$arg" in
        --upload) UPLOAD=1 ;;
        --allow-dirty) ALLOW_DIRTY=1 ;;
        --help|-h) usage; exit 0 ;;
        *) echo "$0: error: unknown option: $arg" >&2; usage >&2; exit 1 ;;
    esac
done

# The release build links the large binaries in one go; linking can open
# thousands of files at once, and on machines with a low default soft fd limit
# (e.g. 1024) the link dies with "ProcessFdQuotaExceeded". Raise the soft
# limit best-effort — the hard limit is typically far higher (here 1048576).
# If this fails, the operator must run the build under a raised limit
# (e.g. `ulimit -n 65536 && just release`) or the link will fail the same way.
if ! ulimit -n 65536 2>/dev/null; then
    echo "warning: could not raise fd limit — release linking may fail with ProcessFdQuotaExceeded; run under a raised limit (ulimit -n 65536)" >&2
fi

REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$REPO_ROOT"

# Dist/release binaries are built on STABLE Rust (see the header comment), so a
# stable toolchain is a hard prerequisite — fail fast with a clear hint instead
# of letting `cargo +stable` fail obscurely mid-build.
if ! rustup toolchain list 2>/dev/null | grep -q '^stable'; then
    echo "error: a stable Rust toolchain is required for release builds — run \`rustup toolchain install stable\`" >&2
    exit 1
fi

# Version comes from the workspace manifest — the single source of truth that
# packaging/homebrew/choreographr.rb and packaging/aur/PKGBUILD mirror.
VERSION="$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -n1)"
[ -n "$VERSION" ] || { echo "error: could not read version from Cargo.toml" >&2; exit 1; }

# Releasing from a dirty tree risks shipping uncommitted changes; the flag is
# an explicit escape hatch for CI-style flows that stage files first.
if [ "$ALLOW_DIRTY" -eq 0 ] && [ -n "$(git status --porcelain)" ]; then
    echo "error: working tree is dirty — commit or stash changes first (or pass --allow-dirty)" >&2
    exit 1
fi

# Host-target detection mirrors scripts/install.sh. (Cross-compiling other
# targets is out of scope for this script.) Both Linux hosts map to the static
# musl triple — the Linux release tarball is a fully static musl build (see
# below), on x86_64 AND aarch64. A Darwin-arm64 host builds BOTH darwin
# tarballs in one pass: native aarch64-apple-darwin plus a cross-built
# x86_64-apple-darwin (Apple's clang targeting x86_64 from an arm64 host is
# first-class and shares the single Xcode SDK both triples need — no extra SDK
# install). Each host builds its own arch's tarball; the CI release workflow
# pairs an x86_64 runner (this musl path) with an arm64 one (Linux-aarch64) so
# both Linux tarballs are produced natively.
case "$(uname -s)-$(uname -m)" in
    Linux-x86_64) TARGET="x86_64-unknown-linux-musl" ;;
    Linux-aarch64) TARGET="aarch64-unknown-linux-musl" ;;
    Darwin-arm64) TARGET="aarch64-apple-darwin" ;;  # host tarball; x86_64 is cross-built below
    *)
        echo "error: unsupported platform: $(uname -s) $(uname -m)" >&2
        echo "error: ${VERSION} ships Linux x86_64 + arm64 (static musl), macOS arm64, and macOS x86_64" >&2
        echo "error: the macOS x86_64 tarball is cross-built on the Darwin-arm64 host" >&2
        exit 1
        ;;
esac

# Linux package arch tag for the .deb/.rpm filenames — the same spelling the
# tarballs use (x86_64 / aarch64), NOT Debian's control-file arch (amd64 /
# arm64, which build-deb.sh maps it to internally). Empty on the macOS target,
# where .deb/.rpm are not built; used both to gate that block and to name its
# artifacts.
case "$TARGET" in
    x86_64-unknown-linux-musl) PKG_ARCH="x86_64" ;;
    aarch64-unknown-linux-musl) PKG_ARCH="aarch64" ;;
    *) PKG_ARCH="" ;;
esac

# The release binaries (must match scripts/install.sh and the formula). The
# IM and ACP bridges are feature-gated (`im` / `acp`) and NOT built for
# release — release binaries ship only the daemon + TUI suite.
BINARIES=(choreographr choreo-tui)

# Build only the shipped binaries, from their two owning packages: the daemon
# (root package `choreographr`) and the TUI (the `choreo-tui` crate) are now
# SEPARATE packages (binary-split refactor), so a single `-p choreographr` no
# longer produces the TUI binary — both packages must be selected explicitly.
# Neither package is choreo-gui (its dioxus/webkit2gtk stack is not shipped
# and must not be a build requirement of the release machine). The bridge
# binaries (`choreo-im`, `choreo-acp`) are required-features-gated and
# therefore skipped by this build. Features use PACKAGE-SCOPED syntax
# (`pkg/feature`) because two packages are selected: with multiple packages,
# bare feature names rely on cargo's ambiguity resolution (a name found in
# exactly one selected package), which silently changes meaning the moment
# the other package grows a same-named feature — explicit scoping is stable
# regardless. `choreographr/metrics,choreographr/blockchain` enable the
# Prometheus `/metrics` endpoint and the EVM/Substrate blockchain tools for
# the daemon — both are off by default so the published crates.io manifests
# stay lean (the metrics machinery and the optional `choreo-blockchain`
# crate, which pulls tokio/alloy/subxt into the binary). Each package
# carries its OWN `mimalloc` feature (cargo rejects `optional = true` in
# [workspace.dependencies]), so the musl build enables it once per package.
# The native PDF tools (pdf_classify / pdf_to_markdown) need no feature
# flag: `pdf-inspector` has been an unconditional dependency since 1.15.0
# replaced the RUSTSEC-2026-0187-vulnerable lopdf ^0.41 pin.

# ── Tarball build ────────────────────────────────────────────────────────────
# The Linux tarballs are fully static musl builds — x86_64-unknown-linux-musl
# (cross-built on the Linux-x86_64 host) and aarch64-unknown-linux-musl (built
# on the Linux-aarch64 host; each host produces its own arch's tarball, and the
# CI workflow pairs an x86_64 runner with an arm64 one).
# A static musl build is viable because the shipped binaries link no C
# libraries: the desktop-notify tool (notify-rust/libdbus-sys — the last C
# dependency) was removed from the daemon, so nothing requires glibc anymore.
# Static musl also replaces the old "build inside an old-glibc container"
# compatibility dance: a musl binary runs on any Linux kernel regardless of
# the host's glibc version. The `mimalloc` feature swaps in mimalloc's
# per-thread allocator, which is markedly better than musl's default malloc
# (see the `#[global_allocator]` blocks in src/bin/*.rs). The macOS tarballs
# are built in the SAME pass on the Darwin-arm64 host: native
# aarch64-apple-darwin plus a cross-built x86_64-apple-darwin (no musl, no
# mimalloc — Apple builds keep the system allocator).
#
# The cross-build runs through cargo-zigbuild because cc-rs passes the full
# Rust triple `<arch>-unknown-linux-musl` to the C compiler, and `zig cc`'s
# target-query grammar rejects the `unknown` vendor slot
# (`UnknownOperatingSystem`); cargo-zigbuild translates the Rust triple to
# zig's grammar (`x86_64-linux-musl`) for both cc-rs and the linker, which is
# the standard solution. zlob is unaffected — its build.rs already maps the
# triple itself.
# `--locked` on every release build is a supply-chain control: it makes the
# committed Cargo.lock authoritative, so a silent lockfile regeneration during
# a release can never pick up a freshly republished (potentially compromised)
# semver-compatible version like the 2026-08-20 arrayref@0.3.10 attack
# (RUSTSEC-2026-0260). The lockfile itself is also checked by
# scripts/check-supply-chain.sh (deny.toml bans) in the release workflow.
#
# The two darwin builds are separate cargo INVOCATIONS even though they share
# a feature set, because they do NOT share RUSTFLAGS (a single invocation with
# two --target triples gets one rustflags value for both) — and only the
# `--target` form is used for the native triple too, so the artifacts land in
# distinct keyed target/<triple>/dist dirs and the staging loop below is
# uniform across all shipped targets.
TARBALL_JOBS=()
if [ "$TARGET" = "x86_64-unknown-linux-musl" ]; then
    echo "==> building release binaries (daemon + TUI packages)"
    # CPU floor: x86-64-v2 (SSE3/SSSE3/SSE4.1/SSE4.2/POPCNT/CMPXCHG16B) — the
    # level every AMD64 CPU since Intel Nehalem (2008) / AMD Bulldozer (2011)
    # implements, and the direction enterprise distros have moved (RHEL 10
    # baseline = v3, SLES 16 = v2; Debian/Arch/Fedora stay v1). RUSTFLAGS env,
    # NOT profile rustflags, because (a) profile rustflags ignore --target and
    # would poison cross builds (the build-android.sh lesson), and (b) env
    # rustflags override any user ~/.cargo/config.toml [build] rustflags (e.g.
    # a developer's target-cpu=native), so local and CI artifacts are
    # comparable. Future per-CPU-level artifacts (e.g. a v3 tarball) reuse this
    # exact mechanism with a different value.
    RUSTFLAGS="-C target-cpu=x86-64-v2" ./scripts/build-stable.sh zigbuild --locked --profile dist -p choreographr -p choreo-tui --target x86_64-unknown-linux-musl --features choreographr/metrics,choreographr/blockchain,choreographr/mimalloc,choreo-tui/mimalloc
    TARBALL_JOBS+=("x86_64-unknown-linux-musl target/x86_64-unknown-linux-musl/dist")
elif [ "$TARGET" = "aarch64-unknown-linux-musl" ]; then
    echo "==> building release binaries (daemon + TUI packages)"
    # aarch64 musl: the SAME fully-static-musl + mimalloc story as x86_64,
    # cross-built through cargo-zigbuild the same way (zig cc compiles the C
    # deps — ring, aws-lc-sys, mimalloc — for the aarch64-linux target). NO
    # target-cpu flag: the generic aarch64 baseline already includes NEON (SIMD
    # is mandatory in AArch64), so there is no x86-style v1/v2/v3 tier split to
    # aim at and the target default is the correct, fleet-safe choice — the
    # same reasoning as the Darwin-arm64 arm below.
    ./scripts/build-stable.sh zigbuild --locked --profile dist -p choreographr -p choreo-tui --target aarch64-unknown-linux-musl --features choreographr/metrics,choreographr/blockchain,choreographr/mimalloc,choreo-tui/mimalloc
    TARBALL_JOBS+=("aarch64-unknown-linux-musl target/aarch64-unknown-linux-musl/dist")
else
    echo "==> building release binaries (daemon + TUI packages)"
    # Native aarch64: NO target-cpu flag — the aarch64-apple-darwin target spec
    # already defaults to apple-a14 (Apple-Silicon-tuned), and the fleet is
    # homogeneous by definition, so the target default is the right answer here.
    ./scripts/build-stable.sh build --locked --profile dist -p choreographr -p choreo-tui --target aarch64-apple-darwin --features choreographr/metrics,choreographr/blockchain
    TARBALL_JOBS+=("aarch64-apple-darwin target/aarch64-apple-darwin/dist")

    # Cross x86_64: -C target-cpu=x86-64-v3 (AVX2/FMA). Unlike the aarch64 case,
    # the x86_64-apple-darwin target defaults to GENERIC baseline x86-64 (2003
    # SSE2), which undershoots the real fleet: the last Intel-capable macOS
    # (26 Tahoe) supports only 2019–2020 Intel Macs (Coffee/Ice/Comet Lake +
    # mac Pro 2019), every one of which implements AVX2 — so v3 DESCRIBES the
    # fleet rather than betting on it (contrast the musl build's v2 floor over
    # an open-ended Linux fleet). The fleet can only shrink from here (macOS 27
    # is Apple-Silicon-only), so v3 can never become too aggressive; Rosetta 2
    # emulates AVX2/FMA, so the binary also stays valid if ever run translated
    # on Apple Silicon. Same env-RUSTFLAGS-not-profile-rustflags reasoning as
    # the musl branch above, and the same reason this build is its own cargo
    # invocation: rustflags differ per triple within one pass.
    echo "==> cross-building the macOS x86_64 tarball"
    # --toolchain stable is REQUIRED (not the default bare invocation): the
    # workspace default toolchain is NIGHTLY (rust-toolchain.toml), so a bare
    # `rustup target add` installs x86_64-apple-darwin std on nightly — and
    # then the build-stable.sh cross build (cargo +stable) fails with
    # E0463 "can't find crate for `core`" because STABLE has no std for that
    # target (seen in the 2026-09-16 release run). add --toolchain stable
    # (build-stable.sh resolves `stable`; this installs for the same toolchain
    # the build actually uses).
    rustup target add x86_64-apple-darwin --toolchain stable
    RUSTFLAGS="-C target-cpu=x86-64-v3" ./scripts/build-stable.sh build --locked --profile dist -p choreographr -p choreo-tui --target x86_64-apple-darwin --features choreographr/metrics,choreographr/blockchain
    TARBALL_JOBS+=("x86_64-apple-darwin target/x86_64-apple-darwin/dist")
fi

# Stage each tarball: the shipped binaries plus both service files, all
# at the top level of the archive (no bin/ prefix) so install.sh and the
# Homebrew formula can reference them directly. tar preserves exec bits.
# Every entry of TARBALL_JOBS (“triple bindir”) MUST produce a tarball — the
# hard-fail below fires on a missing binary BEFORE the SHA256SUMS step, so a
# half-successful pass can never emit an incomplete checksum file (with two
# darwin tarballs in one run, a skipped x86_64 cross build must abort, not
# silently checksum only the arm64 one).
mkdir -p dist
STAGE=""
trap 'rm -rf "$STAGE"' EXIT
TARBALL=""
for job in "${TARBALL_JOBS[@]}"; do
    triple="${job%% *}"
    bindir="${job#* }"
    STAGE="$(mktemp -d)"
    for b in "${BINARIES[@]}"; do
        [ -x "$bindir/$b" ] || {
            echo "error: missing $bindir/$b — build did not produce $triple's binary set" >&2
            exit 1
        }
        install -m 0755 "$bindir/$b" "$STAGE/$b"
    done
    install -m 0644 packaging/choreographr.service "$STAGE/choreographr.service"
    install -m 0644 packaging/com.choreographr.daemon.plist "$STAGE/com.choreographr.daemon.plist"

    TARBALL="dist/choreographr-${VERSION}-${triple}.tar.gz"
    tar czf "$TARBALL" -C "$STAGE" \
        "${BINARIES[@]}" choreographr.service com.choreographr.daemon.plist
    rm -rf "$STAGE"
    STAGE=""
done

# ── .deb/.rpm build (host glibc, no mimalloc) ───────────────────────────────
# The .deb/.rpm stay native glibc host-target builds WITHOUT the mimalloc
# feature and WITHOUT the musl target: they target glibc distros
# (Debian/Fedora/openSUSE), where the system allocator is competitive — static
# musl + mimalloc is a property of the tarball (which serves general Linux AND
# the AUR `-bin` package in one artifact), not of the distro packages. They
# consume `target/dist/` from a plain host build; the musl tarball build
# above does NOT populate that directory, so build it here. (On macOS this
# step is skipped — dpkg/rpmbuild are not present.) The block runs on EITHER
# Linux host: build-deb.sh/build-rpm.sh detect the host arch and name the
# artifacts accordingly (x86_64 / aarch64), so a native arm64 box produces the
# arm64 .deb/.rpm the same way.
if [ -n "$PKG_ARCH" ]; then
    echo "==> building host (glibc) dist binaries for .deb/.rpm"
    # Deliberately NO target-cpu: the .deb/.rpm serve the full glibc-distro
    # range, whose baselines are split (Debian/Arch/Fedora = v1, RHEL 10 =
    # v3), so baseline (2003 SSE2 on x86-64; the generic aarch64 baseline on
    # arm64) is the only level that covers them all.
    ./scripts/build-stable.sh build --locked --profile dist -p choreographr -p choreo-tui --features choreographr/metrics,choreographr/blockchain

    # .deb/.rpm are best-effort: skip with a warning when the toolchain is
    # absent so a Linux release can still proceed without dpkg/rpmbuild
    # installed. The whole block is gated on the Linux package arch because
    # .deb/.rpm are Linux-only artifacts — on the macOS build these checks
    # would otherwise print irrelevant "dpkg-deb/rpmbuild not found" warnings
    # (seen for real in the 2026-08-31 workflow_dispatch macOS job).
    if command -v dpkg-deb >/dev/null 2>&1; then
        "$REPO_ROOT/scripts/build-deb.sh"
    else
        echo "warning: dpkg-deb not found — skipping .deb (install with: pacman -S dpkg)" >&2
    fi
    if command -v rpmbuild >/dev/null 2>&1; then
        "$REPO_ROOT/scripts/build-rpm.sh"
    else
        echo "warning: rpmbuild not found — skipping .rpm (install with: pacman -S rpm-tools)" >&2
    fi
fi

# ── Checksums over EVERY artifact for this version ──────────────────────────
# SHA256SUMS ships beside the tarball (install.sh verifies against this file).
# It covers every `choreographr-${VERSION}-*` file already in dist/: every
# tarball this pass built (on the macOS host: BOTH the native arm64 and the
# cross-built x86_64 tarballs), the .deb/.rpm just built above, and any
# other-arch tarball an operator staged into dist/ before upload (the CI
# release job builds x86_64 and arm64 on separate runners and merges their
# artifacts, so the combined file spans both). Regenerating
# here, after the .deb/.rpm
# step and from the glob rather than the single host tarball, means a combined
# file is produced and `--upload` never clobbers it with a single-host one.
( cd dist && sha256sum choreographr-${VERSION}-* > SHA256SUMS )

echo
echo "==> artifacts in dist/:"
ls -lh dist/

# Assemble the artifact list the same way for printing and uploading: every
# tarball present in dist/ for this version (the host's plus any staged
# other-arch tarballs), the checksum file, then the .deb/.rpm when built. The
# glob always matches at least the host tarball just created, so it needs no
# nullglob guard under `set -u`. The .deb/.rpm are named for THIS host's
# package arch (PKG_ARCH is empty on macOS, where neither exists).
GH_ARTIFACTS=("dist/SHA256SUMS")
for tarball in dist/choreographr-${VERSION}-*.tar.gz; do
    GH_ARTIFACTS+=("$tarball")
done
[ -f "dist/choreographr-${VERSION}-${PKG_ARCH}.deb" ] && GH_ARTIFACTS+=("dist/choreographr-${VERSION}-${PKG_ARCH}.deb")
[ -f "dist/choreographr-${VERSION}-${PKG_ARCH}.rpm" ] && GH_ARTIFACTS+=("dist/choreographr-${VERSION}-${PKG_ARCH}.rpm")

echo
echo "==> validate before uploading:"
echo "    scripts/smoke-test.sh ${TARBALL}"
echo
echo "==> release command (run manually, or re-run this script with --upload):"
echo "    scripts/release-notes.sh ${VERSION} > /tmp/release-notes.md"
echo "    gh release create v${VERSION} ${GH_ARTIFACTS[*]} --title \"choreographr ${VERSION}\" --notes-file /tmp/release-notes.md"
echo
echo "==> post-publish checklist:"
echo "  - Homebrew: bump packaging/homebrew/choreographr.rb (version, urls,"
echo "    shasum -a 256) and push to the choreographr/homebrew-choreographr tap"
echo "  - AUR: bump pkgver in packaging/aur/PKGBUILD + regenerate .SRCINFO"
echo "    (makepkg --printsrcinfo > .SRCINFO)"
echo "  - crates.io: cargo release (version bump + tag + publish of the 13"
echo "    publish-set members in dependency order) runs BEFORE this script;"
echo "    the metrics and blockchain tools are feature-gated and off by default"
echo "    on crates.io — release binaries build them via"
echo "    --features metrics,blockchain)"
echo "  - choreographr.com: publish scripts/install.sh and add download"
echo "    redirects for v${VERSION}"

if [ "$UPLOAD" -eq 1 ]; then
    command -v gh >/dev/null 2>&1 || {
        echo "error: gh not found — install it (e.g. pacman -S github-cli)" >&2
        exit 1
    }
    echo
    echo "==> uploading release v${VERSION}"
    # Release notes are generated from the commit messages (git-cliff), matching
    # the CI release job — never the GitHub auto-generated notes.
    NOTES="$(mktemp)"
    ./scripts/release-notes.sh "${VERSION}" > "$NOTES"
    gh release create "v${VERSION}" "${GH_ARTIFACTS[@]}" \
        --title "choreographr ${VERSION}" --notes-file "$NOTES"
    rm -f "$NOTES"
    echo "==> upload complete"
fi
