Vectrum
Sign In
304 lines · 13.7 KB · Shell
#!/usr/bin/env bash
# Builds Vectrum Code in release mode and writes two artifacts to ./build:
#   build/vectrum-code                       plain binary
#   build/Vectrum_Code-<version>-<arch>.AppImage
# and, with -a, a third:
#   build/Vectrum_Code-<versionName>-<versionCode>.apk
#
# A binary only runs on the glibc it was linked against or a newer one, so by
# default it is built in an Ubuntu 24.04 container and linked against glibc
# 2.39: it runs on Ubuntu 24.04, Fedora 40, Debian 13 and anything newer. The
# image is built once; its target dir and cargo cache stay in
# target/ubuntu-24.04. Docker is used directly, or through sudo when only root
# can reach it; set DOCKER="sudo docker" (or another command) to choose.
#
# appimagetool is used from $PATH if present, otherwise downloaded once into
# build/.tools. Set APPIMAGETOOL=/path/to/appimagetool to use a specific one.
#
# Options:
#   -v, --version X     set the version to X (0.1.2) first: updates Cargo.toml
#                       and Cargo.lock and commits them as "Version X"
#   -r, --release       then publish the build as a release on Vectrum: tag the
#                       commit with the version, push the tag, and upload the
#                       AppImage, the binary (and the APK with -a) and SHA256SUMS
#                       (the first upload publishes the release). Each release
#                       needs a new version.
#   -a, --android       also build the Android app (android/) as a release APK,
#                       with the versionCode and versionName that
#                       android/app/build.gradle.kts defines. Needs JDK 21 and
#                       the Android SDK (android/local.properties or ANDROID_HOME)
#   --native            build with the host toolchain instead of the container;
#                       the binary then needs the host's glibc or newer
#   -h, --help          show this
#
# So ./build.sh -v 0.1.2 -r makes and publishes version 0.1.2. Releasing reads:
#   VECTRUM_TOKEN       a read & write API token (vec_…); otherwise read from
#                       ~/.config/vectrum/token (or $VECTRUM_TOKEN_FILE)
#   VECTRUM_URL         the server, https://vectrum.cc by default
#   VECTRUM_REPO        owner/repo, taken from the origin remote by default
set -euo pipefail

log() { printf '\033[1;34m==>\033[0m %s\n' "$*"; }
die() { printf '\033[1;31mxx\033[0m %s\n' "$*" >&2; exit 1; }

RELEASE=0
NATIVE=0
ANDROID=0
NEW_VERSION=""
while (( $# )); do
    case "$1" in
        -r|--release) RELEASE=1 ;;
        -a|--android) ANDROID=1 ;;
        --native) NATIVE=1 ;;
        -v|--version)
            [[ $# -ge 2 ]] || die "$1 needs a version, like $1 0.1.2"
            NEW_VERSION="$2"
            shift
            ;;
        --version=*) NEW_VERSION="${1#*=}" ;;
        -h|--help) sed -n '2,/^set -euo/p' "$0" | sed '$d; s/^# \{0,1\}//'; exit 0 ;;
        *) printf 'Unknown option: %s (try --help)\n' "$1" >&2; exit 2 ;;
    esac
    shift
done

ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
BUILD="$ROOT/build"
TOOLS="$BUILD/.tools"
APPDIR="$BUILD/AppDir"

NAME="vectrum-code"
PRETTY="Vectrum Code"
VERSION="$(sed -n 's/^version *= *"\(.*\)"/\1/p' "$ROOT/Cargo.toml" | head -n1)"
ARCH="$(uname -m)"

dirty() { [[ -n "$(git -C "$ROOT" status --porcelain)" ]]; }

# Stops if tag $1 already names a commit other than $2 (any commit, if $2 is
# empty), here or on origin. Sets REMOTE_TAG and LOCAL_TAG.
check_tag() {
    REMOTE_TAG="$(git -C "$ROOT" ls-remote origin "refs/tags/$1^{}" "refs/tags/$1" | awk 'NR==1 || /\^\{\}$/ {sha=$1} END {print sha}')"
    LOCAL_TAG="$(git -C "$ROOT" rev-parse -q --verify "refs/tags/$1^{commit}" || true)"
    for tagged in "$REMOTE_TAG" "$LOCAL_TAG"; do
        [[ -z "$tagged" || "$tagged" == "$2" ]] ||
            die "$1 is already tagged at ${tagged:0:7}; pick a new version with -v"
    done
}

# ── 0. Version, release and Docker checks, before spending time on a build ───
if (( ! NATIVE )); then
    if [[ -n "${DOCKER:-}" ]]; then
        read -ra DOCKER <<< "$DOCKER"
    elif docker info >/dev/null 2>&1; then
        DOCKER=(docker)
    elif sudo -n docker info >/dev/null 2>&1; then
        DOCKER=(sudo docker)
    else
        DOCKER=(docker)
    fi
    command -v "${DOCKER[0]}" >/dev/null || die "Docker is needed for this build (or use --native)"
    "${DOCKER[@]}" info >/dev/null 2>&1 ||
        die "Can't reach Docker as ${DOCKER[*]}: start it, join the docker group, set DOCKER=\"sudo docker\", or use --native"
fi

if (( ANDROID )); then
    GRADLE_APP="$ROOT/android/app/build.gradle.kts"
    [[ -f "$GRADLE_APP" ]] || die "No Android app at android/app/build.gradle.kts"
    APK_CODE="$(sed -n 's/^ *versionCode *= *\([0-9][0-9]*\).*/\1/p' "$GRADLE_APP" | tail -n1)"
    APK_VERSION="$(sed -n 's/^ *versionName *= *"\(.*\)".*/\1/p' "$GRADLE_APP" | tail -n1)"
    [[ -n "$APK_CODE" && -n "$APK_VERSION" ]] ||
        die "Couldn't read versionCode and versionName from android/app/build.gradle.kts"
    command -v java >/dev/null || die "The Android build needs a JDK (21) on \$PATH or in JAVA_HOME"
    [[ -n "${ANDROID_HOME:-}${ANDROID_SDK_ROOT:-}" ]] || grep -qs '^sdk\.dir=' "$ROOT/android/local.properties" ||
        die "The Android build needs the SDK: set ANDROID_HOME or sdk.dir in android/local.properties"
fi

if (( RELEASE )); then
    SERVER="${VECTRUM_URL:-https://vectrum.cc}"
    SERVER="${SERVER%/}"
    TOKEN_FILE="${VECTRUM_TOKEN_FILE:-$HOME/.config/vectrum/token}"
    TOKEN="${VECTRUM_TOKEN:-}"
    if [[ -z "$TOKEN" && -r "$TOKEN_FILE" ]]; then
        TOKEN="$(tr -d '[:space:]' < "$TOKEN_FILE")"
    fi
    [[ -n "$TOKEN" ]] || die "No API token: set VECTRUM_TOKEN or save a read & write token to $TOKEN_FILE"
    # ssh://git@git.vectrum.cc:2222/vectrum/code.git, https://…/vectrum/code → vectrum/code
    REPO="${VECTRUM_REPO:-$(git -C "$ROOT" remote get-url origin | sed -E 's#\.git$##; s#^.*[:/]([^/:]+/[^/:]+)$#\1#')}"
    [[ "$REPO" == */* ]] || die "Couldn't tell the repository from the origin remote; set VECTRUM_REPO=owner/repo"
fi

if [[ -n "$NEW_VERSION" && "$NEW_VERSION" != "$VERSION" ]]; then
    [[ "$NEW_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.-]+)?$ ]] ||
        die "$NEW_VERSION isn't a version like 0.1.2"
    ! dirty || die "Commit or stash your changes first: the new version is committed on its own"
    if (( RELEASE )); then
        check_tag "$NEW_VERSION" ""
    fi
    log "Version $VERSION → $NEW_VERSION"
    cargo fetch --locked --quiet --manifest-path "$ROOT/Cargo.toml"
    sed -i "0,/^version *= *\".*\"/s//version = \"$NEW_VERSION\"/" "$ROOT/Cargo.toml"
    cargo update --workspace --offline --quiet --manifest-path "$ROOT/Cargo.toml"
    git -C "$ROOT" commit -q -m "Version $NEW_VERSION" -- Cargo.toml Cargo.lock
    VERSION="$NEW_VERSION"
fi

if (( RELEASE )); then
    TAG="$VERSION"
    ! dirty || die "Commit or stash your changes first: a release is built from a commit"
    HEAD="$(git -C "$ROOT" rev-parse HEAD)"
    check_tag "$TAG" "$HEAD"
    log "Releasing $TAG of $REPO on $SERVER from ${HEAD:0:7}"
fi

# ── 1. Binary ────────────────────────────────────────────────────────────────
mkdir -p "$BUILD"
if (( NATIVE )); then
    log "Building $NAME $VERSION (release, $ARCH, host toolchain)"
    cargo build --release --manifest-path "$ROOT/Cargo.toml"

    TARGET_DIR="$(cargo metadata --format-version 1 --no-deps --manifest-path "$ROOT/Cargo.toml" |
        sed -n 's/.*"target_directory":"\([^"]*\)".*/\1/p')"
else
    IMAGE="vectrum-code-build:ubuntu-24.04"
    GLIBC_MAX="2.39"
    TARGET_DIR="$ROOT/target/ubuntu-24.04"

    log "Preparing the Ubuntu 24.04 build image"
    "${DOCKER[@]}" build --quiet -t "$IMAGE" - >/dev/null <<'EOF'
FROM ubuntu:24.04
RUN apt-get update \
 && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
      ca-certificates curl build-essential pkg-config clang libclang-dev cmake \
      libxcb1-dev libxkbcommon-dev libxkbcommon-x11-dev libwayland-dev \
      libfontconfig-dev libfreetype-dev libvulkan-dev \
 && rm -rf /var/lib/apt/lists/*
ENV RUSTUP_HOME=/opt/rustup CARGO_HOME=/opt/cargo PATH=/opt/cargo/bin:$PATH
RUN curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal --no-modify-path \
 && chmod -R a+rwX /opt/rustup /opt/cargo
EOF

    log "Building $NAME $VERSION (release, $ARCH, Ubuntu 24.04 / glibc $GLIBC_MAX)"
    mkdir -p "$TARGET_DIR/cargo-home"
    # Runs as the calling user so target/ and build/ stay theirs, even through sudo.
    "${DOCKER[@]}" run --rm --init \
        --user "$(id -u):$(id -g)" \
        -e HOME=/tmp \
        -e CARGO_HOME=/src/target/ubuntu-24.04/cargo-home \
        -e CARGO_TARGET_DIR=/src/target/ubuntu-24.04 \
        -v "$ROOT:/src" -w /src \
        "$IMAGE" cargo build --release --locked
fi

install -m755 "$TARGET_DIR/release/$NAME" "$BUILD/$NAME"

if (( ! NATIVE )); then
    # Stop if anything still pulled in a glibc symbol newer than the container's.
    NEED="$(objdump -T "$BUILD/$NAME" | grep -o 'GLIBC_[0-9.]*' | sed 's/GLIBC_//' | sort -uV | tail -n1)"
    [[ "$(printf '%s\n%s\n' "$NEED" "$GLIBC_MAX" | sort -V | tail -n1)" == "$GLIBC_MAX" ]] ||
        die "build/$NAME needs glibc $NEED, more than Ubuntu 24.04's $GLIBC_MAX"
    log "Binary needs glibc $NEED or newer"
fi
log "Binary: build/$NAME"

# ── 2. AppDir ────────────────────────────────────────────────────────────────
rm -rf "$APPDIR"
mkdir -p "$APPDIR/usr/bin" \
         "$APPDIR/usr/share/applications" \
         "$APPDIR/usr/share/icons/hicolor/scalable/apps"

install -m755 "$BUILD/$NAME" "$APPDIR/usr/bin/$NAME"

cat > "$APPDIR/usr/share/applications/$NAME.desktop" <<EOF
[Desktop Entry]
Type=Application
Name=$PRETTY
Comment=A T3 Code-style GUI for Claude Code
Exec=$NAME
Icon=$NAME
Terminal=false
Categories=Development;IDE;
StartupWMClass=$NAME
EOF

install -m644 "$ROOT/assets/icons/$NAME.svg" "$APPDIR/usr/share/icons/hicolor/scalable/apps/$NAME.svg"

cat > "$APPDIR/AppRun" <<'EOF'
#!/bin/sh
HERE="$(dirname "$(readlink -f "$0")")"
exec "$HERE/usr/bin/vectrum-code" "$@"
EOF
chmod +x "$APPDIR/AppRun"

ln -sf "usr/share/applications/$NAME.desktop" "$APPDIR/$NAME.desktop"
ln -sf "usr/share/icons/hicolor/scalable/apps/$NAME.svg" "$APPDIR/$NAME.svg"
ln -sf "$NAME.svg" "$APPDIR/.DirIcon"

# ── 3. appimagetool ──────────────────────────────────────────────────────────
APPIMAGETOOL="${APPIMAGETOOL:-$(command -v appimagetool || true)}"
if [[ -z "$APPIMAGETOOL" ]]; then
    APPIMAGETOOL="$TOOLS/appimagetool-$ARCH.AppImage"
    if [[ ! -x "$APPIMAGETOOL" ]]; then
        log "Downloading appimagetool"
        mkdir -p "$TOOLS"
        curl -fL --progress-bar -o "$APPIMAGETOOL.part" \
            "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-$ARCH.AppImage"
        chmod +x "$APPIMAGETOOL.part"
        mv "$APPIMAGETOOL.part" "$APPIMAGETOOL"
    fi
fi

# ── 4. AppImage ──────────────────────────────────────────────────────────────
OUT="$BUILD/${PRETTY// /_}-$VERSION-$ARCH.AppImage"
log "Packaging AppImage"
# Extract-and-run so this works on systems without FUSE (containers, CI).
APPIMAGE_EXTRACT_AND_RUN=1 ARCH="$ARCH" "$APPIMAGETOOL" --no-appstream "$APPDIR" "$OUT"
rm -rf "$APPDIR"

log "AppImage: ${OUT#"$ROOT"/}"

# ── 5. Android ───────────────────────────────────────────────────────────────
if (( ANDROID )); then
    APK="$BUILD/${PRETTY// /_}-$APK_VERSION-$APK_CODE.apk"
    log "Building the Android app $APK_VERSION (versionCode $APK_CODE, release)"
    (cd "$ROOT/android" && ./gradlew --quiet :app:assembleRelease)
    install -m644 "$ROOT/android/app/build/outputs/apk/release/app-release.apk" "$APK"
    log "APK: ${APK#"$ROOT"/}"
fi

# ── 6. Release ───────────────────────────────────────────────────────────────
if (( RELEASE )); then
    STAGE="$BUILD/release-$TAG"
    rm -rf "$STAGE"
    mkdir -p "$STAGE"
    cp "$OUT" "$STAGE/"
    cp "$BUILD/$NAME" "$STAGE/$NAME-$VERSION-$ARCH"
    UPLOADS=("$(basename "$OUT")" "$NAME-$VERSION-$ARCH")
    if (( ANDROID )); then
        cp "$APK" "$STAGE/"
        UPLOADS+=("$(basename "$APK")")
    fi
    (cd "$STAGE" && sha256sum -- * > SHA256SUMS)

    if [[ -z "$LOCAL_TAG" ]]; then
        git -C "$ROOT" tag -a "$TAG" -m "$PRETTY $VERSION" "$HEAD"
    fi
    if [[ -z "$REMOTE_TAG" ]]; then
        log "Pushing tag $TAG"
        git -C "$ROOT" push -q origin "refs/tags/$TAG"
    fi

    # The checksums go last, so the release never shows them alone.
    for name in "${UPLOADS[@]}" SHA256SUMS; do
        file="$STAGE/$name"
        log "Uploading $name"
        # The token goes in through a file descriptor, so it never shows in ps.
        if ! reply="$(curl -sS --fail-with-body -T "$file" -H @<(printf 'Authorization: Bearer %s\n' "$TOKEN") \
            "$SERVER/$REPO/releases/download/$TAG/$name" 2>&1)"; then
            die "Uploading $name failed: $reply (run the same command again to retry)"
        fi
    done
    log "Released: $SERVER/$REPO/releases/tag/$TAG"
fi
if [[ -n "$NEW_VERSION" ]] && [[ -n "$(git -C "$ROOT" log --oneline '@{upstream}..HEAD' 2>/dev/null || echo unpushed)" ]]; then
    log "The version commit is only on your $(git -C "$ROOT" branch --show-current) branch here: git push to share it"
fi