#!/usr/bin/env bash # Builds Vectrum Code in release mode and writes two artifacts to ./build: # build/vectrum-code plain binary # build/Vectrum_Code--.AppImage # and, with -a, a third: # build/Vectrum_Code-.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), CHANGES.md # (the commits since the last release), SHA256SUMS and # its signature SHA256SUMS.sig (the first upload # publishes the release). Each release needs a new # version. The app only installs updates whose sums are # signed with the release key. # -a, --android also build the Android app (android/) as a release APK. # It has the desktop version as its versionName, and # versionCode major*1000000 + minor*1000 + patch (0.1.9 → # 1009); android/app/build.gradle.kts reads Cargo.toml. # 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 # VECTRUM_SIGNING_KEY the Ed25519 release key (PEM) whose public half is in # src/update.rs; ~/.config/vectrum/release-signing-key.pem # by default. Make one with # openssl genpkey -algorithm ed25519 -out # and put the output of # openssl pkey -in -pubout -outform DER | tail -c 32 | base64 # in RELEASE_KEYS. Keep it safe: installed apps only # update to releases it signed. 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 [[ -x "$ROOT/android/gradlew" ]] || die "No Android app at android/" 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" SIGNING_KEY="${VECTRUM_SIGNING_KEY:-$HOME/.config/vectrum/release-signing-key.pem}" [[ -r "$SIGNING_KEY" ]] || die "No release signing key at $SIGNING_KEY: set VECTRUM_SIGNING_KEY (see --help)" command -v openssl >/dev/null || die "Signing the release needs openssl" PUBLIC_KEY="$(openssl pkey -in "$SIGNING_KEY" -pubout -outform DER | tail -c 32 | base64)" grep -qF "\"$PUBLIC_KEY\"" "$ROOT/src/update.rs" || die "$SIGNING_KEY isn't a release key the app knows: add $PUBLIC_KEY to RELEASE_KEYS in src/update.rs" 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" < "$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// /_}-$VERSION.apk" APK_OUT="$ROOT/android/app/build/outputs/apk/release" log "Building the Android app $VERSION (release)" (cd "$ROOT/android" && ./gradlew --quiet :app:assembleRelease) APK_VERSION="$(sed -n 's/.*"versionName": *"\([^"]*\)".*/\1/p' "$APK_OUT/output-metadata.json")" APK_CODE="$(sed -n 's/.*"versionCode": *\([0-9]*\).*/\1/p' "$APK_OUT/output-metadata.json")" [[ "$APK_VERSION" == "$VERSION" ]] || die "The APK came out as version $APK_VERSION, not $VERSION" install -m644 "$APK_OUT/app-release.apk" "$APK" log "APK: ${APK#"$ROOT"/} (versionCode $APK_CODE)" 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 # What changed since the last release, from the commit subjects. PREVIOUS="$(git -C "$ROOT" tag --merged "$HEAD" --sort=-v:refname | grep -vxF "$TAG" | grep -E '^[0-9]+\.[0-9]+\.[0-9]+' | head -n1 || true)" { printf '# %s %s\n\n' "$PRETTY" "$VERSION" git -C "$ROOT" log --no-merges --format='- %s' "${PREVIOUS:+$PREVIOUS..}$HEAD" | grep -vE '^- Version [0-9]' || true } > "$STAGE/CHANGES.md" UPLOADS+=(CHANGES.md) (cd "$STAGE" && sha256sum -- * > SHA256SUMS) log "Signing SHA256SUMS" openssl pkeyutl -sign -inkey "$SIGNING_KEY" -rawin -in "$STAGE/SHA256SUMS" -out "$STAGE/SHA256SUMS.sig" openssl pkeyutl -verify -pubin -inkey <(openssl pkey -in "$SIGNING_KEY" -pubout) -rawin \ -in "$STAGE/SHA256SUMS" -sigfile "$STAGE/SHA256SUMS.sig" >/dev/null || die "The signature on SHA256SUMS doesn't verify" 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, after their signature, so the app never sees # a release with only part of it. for name in "${UPLOADS[@]}" SHA256SUMS.sig 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