| 1 | #!/usr/bin/env bash |
| 2 | # |
| 3 | # Build a Steid release artefact: a tarball that extracts to a self-contained |
| 4 | # directory containing the binary, the asset bundle beside it, and a README. |
| 5 | # |
| 6 | # steid-<version>-<target>/ |
| 7 | # steid |
| 8 | # assets/ <- manifest.toml + content-hashed CSS |
| 9 | # README.md |
| 10 | # |
| 11 | # Why a plain binary and not a container: Steid is meant to be installable on a |
| 12 | # £4 VPS by someone who does not run Docker. The container image still exists |
| 13 | # (see ./Dockerfile) and this script uses Docker as a *build* tool, but nothing |
| 14 | # in the shipped artefact depends on it. |
| 15 | # |
| 16 | # THE BUILD COMMAND MATTERS. `cargo build --release` alone produces a binary |
| 17 | # that will not boot: `main` calls `AssetBundle::load()`, which walks up from the |
| 18 | # executable looking for `assets/manifest.toml`, and `build.rs` never writes one. |
| 19 | # `topcoat asset bundle --release` runs `cargo build --release` itself, then |
| 20 | # scans the linked binary for the assets it declares and writes them plus the |
| 21 | # manifest to `target/assets`. That is the only supported way to build Steid. |
| 22 | # |
| 23 | # ON MACOS you cannot produce a Linux artefact with the host toolchain — there is |
| 24 | # no linker for it and `build.rs` runs a platform-specific Tailwind binary. This |
| 25 | # script therefore builds inside a container of the target platform by default |
| 26 | # (Docker, with qemu emulation when the arch differs from the host). `--native` |
| 27 | # skips all that and builds with the local toolchain, which is what you want for |
| 28 | # a quick smoke test of the artefact layout, not for a release. |
| 29 | # |
| 30 | # Usage: |
| 31 | # ./release.sh # default target, via Docker |
| 32 | # ./release.sh --target aarch64-unknown-linux-gnu |
| 33 | # ./release.sh --all # every target a published release carries |
| 34 | # ./release.sh --native # host target, local toolchain |
| 35 | # ./release.sh --target x86_64-unknown-linux-gnu --version 0.1.0 |
| 36 | # |
| 37 | set -euo pipefail |
| 38 | |
| 39 | # --- parameters ------------------------------------------------------------- |
| 40 | |
| 41 | # glibc, not musl — measured, then decided. |
| 42 | # |
| 43 | # musl was tried and failed on `ring`: Debian's `musl-gcc` wrapper rejects the |
| 44 | # `-m64` that cc-rs passes, so it would need a real cross toolchain rather than |
| 45 | # `musl-tools`. But the decisive argument is not that it was awkward. **musl buys |
| 46 | # a binary with no runtime dependencies, and Steid hard-requires `git` on PATH** — |
| 47 | # anyone installing this already has a package manager and a distro, so the |
| 48 | # portability is unusable. The musl path below still works if a cross toolchain |
| 49 | # ever makes it worthwhile; nothing else in the script cares which is chosen. |
| 50 | TARGET="${STEID_RELEASE_TARGET:-x86_64-unknown-linux-gnu}" |
| 51 | |
| 52 | # Bullseye pins the glibc floor at 2.31, which covers Debian 11+ and Ubuntu |
| 53 | # 20.04+. Building on bookworm would need 2.36 and silently exclude Ubuntu 22.04, |
| 54 | # which is still everywhere — and the failure lands on the user as |
| 55 | # `GLIBC_2.36 not found`, at startup, with nothing pointing at the build. |
| 56 | # |
| 57 | # Pinned to 1.97 for the same reason the Dockerfile pins it: rustc >= 1.95 is a |
| 58 | # hard floor for Topcoat 0.5, and on an older toolchain `topcoat` silently |
| 59 | # resolves to an empty `v0.0.0` placeholder instead of failing. |
| 60 | RUST_IMAGE="${STEID_RUST_IMAGE:-rust:1.97-slim-bullseye}" |
| 61 | TOPCOAT_CLI_VERSION="${STEID_TOPCOAT_CLI_VERSION:-0.5.0}" |
| 62 | |
| 63 | VERSION="" |
| 64 | OUT_DIR="dist" |
| 65 | NATIVE=0 |
| 66 | ALL=0 |
| 67 | |
| 68 | # Every target a published release carries. `install.sh` picks between them from |
| 69 | # `uname -m`, so this list and its architecture detection have to agree: adding a |
| 70 | # target here without teaching the installer about it produces a tarball nobody |
| 71 | # ever downloads. |
| 72 | ALL_TARGETS="x86_64-unknown-linux-gnu aarch64-unknown-linux-gnu" |
| 73 | |
| 74 | usage() { |
| 75 | sed -n '2,32p' "$0" | sed 's/^#\{1,2\} \{0,1\}//' |
| 76 | exit "${1:-0}" |
| 77 | } |
| 78 | |
| 79 | while [ $# -gt 0 ]; do |
| 80 | case "$1" in |
| 81 | --target) TARGET="${2:?--target needs a triple}"; shift 2 ;; |
| 82 | --version) VERSION="${2:?--version needs a value}"; shift 2 ;; |
| 83 | --out) OUT_DIR="${2:?--out needs a directory}"; shift 2 ;; |
| 84 | --native) NATIVE=1; shift ;; |
| 85 | --all) ALL=1; shift ;; |
| 86 | -h|--help) usage 0 ;; |
| 87 | *) echo "release.sh: unknown argument: $1" >&2; usage 1 ;; |
| 88 | esac |
| 89 | done |
| 90 | |
| 91 | REPO_ROOT="$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd)" |
| 92 | cd "$REPO_ROOT" |
| 93 | |
| 94 | # --all re-runs this script once per target rather than looping inline, so a single |
| 95 | # failure aborts the whole release instead of leaving a half-published set where some |
| 96 | # architectures have today's build and others have last week's. |
| 97 | if [ "$ALL" = 1 ]; then |
| 98 | for t in $ALL_TARGETS; do |
| 99 | "$0" --target "$t" ${VERSION:+--version "$VERSION"} --out "$OUT_DIR" || exit 1 |
| 100 | done |
| 101 | echo |
| 102 | echo "release.sh: built every target" |
| 103 | find "$OUT_DIR" -maxdepth 1 -name '*.tar.gz' -exec echo ' {}' \; |
| 104 | exit 0 |
| 105 | fi |
| 106 | |
| 107 | # The version is the crate version unless overridden. Read with grep rather than |
| 108 | # a TOML parser so this script has no dependencies of its own. |
| 109 | if [ -z "$VERSION" ]; then |
| 110 | VERSION="$(grep -m1 '^version *= *"' Cargo.toml | sed 's/.*"\(.*\)".*/\1/')" |
| 111 | fi |
| 112 | [ -n "$VERSION" ] || { echo "release.sh: could not determine version" >&2; exit 1; } |
| 113 | |
| 114 | if [ "$NATIVE" = 1 ]; then |
| 115 | TARGET="$(rustc -vV | sed -n 's/^host: //p')" |
| 116 | fi |
| 117 | |
| 118 | NAME="steid-${VERSION}-${TARGET}" |
| 119 | STAGE="${OUT_DIR}/${NAME}" |
| 120 | |
| 121 | echo "release.sh: building ${NAME}" |
| 122 | |
| 123 | # --- build ------------------------------------------------------------------ |
| 124 | |
| 125 | rm -rf "$STAGE" |
| 126 | mkdir -p "$STAGE" |
| 127 | |
| 128 | if [ "$NATIVE" = 1 ]; then |
| 129 | command -v topcoat >/dev/null 2>&1 || { |
| 130 | echo "release.sh: topcoat CLI not found." >&2 |
| 131 | echo " cargo install topcoat-cli --version ${TOPCOAT_CLI_VERSION} --locked" >&2 |
| 132 | exit 1 |
| 133 | } |
| 134 | echo "release.sh: local build (host toolchain) — NOT a release artefact" |
| 135 | topcoat asset bundle --release |
| 136 | cp target/release/steid "$STAGE/steid" |
| 137 | cp -R target/assets "$STAGE/assets" |
| 138 | else |
| 139 | command -v docker >/dev/null 2>&1 || { |
| 140 | echo "release.sh: docker not found, and a Linux artefact cannot be built" >&2 |
| 141 | echo " with the host toolchain. Install Docker, or run on Linux with" >&2 |
| 142 | echo " --native, or use a cross toolchain." >&2 |
| 143 | exit 1 |
| 144 | } |
| 145 | |
| 146 | case "$TARGET" in |
| 147 | x86_64-*) PLATFORM="linux/amd64" ;; |
| 148 | aarch64-*) PLATFORM="linux/arm64" ;; |
| 149 | *) echo "release.sh: don't know the Docker platform for ${TARGET}" >&2; exit 1 ;; |
| 150 | esac |
| 151 | |
| 152 | # musl needs the cross-linker and the std for the target. On a glibc builder |
| 153 | # image this is a cross-compile even when the arch matches the host, which is |
| 154 | # the point: build.rs keeps running against glibc. |
| 155 | EXTRA_SETUP="" |
| 156 | BIN_PATH="target/release/steid" |
| 157 | case "$TARGET" in |
| 158 | *-musl) |
| 159 | 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}" |
| 160 | BIN_PATH="target/${TARGET}/release/steid" |
| 161 | ;; |
| 162 | esac |
| 163 | |
| 164 | echo "release.sh: building in ${RUST_IMAGE} on ${PLATFORM}" |
| 165 | |
| 166 | # A throwaway image built from a heredoc rather than the repo Dockerfile: that |
| 167 | # one produces a runtime *image*, this one produces files to copy out. Keeping |
| 168 | # them separate means neither has to compromise for the other. |
| 169 | IMAGE_TAG="steid-release-build:${VERSION}-${TARGET}" |
| 170 | docker buildx build \ |
| 171 | --platform "$PLATFORM" \ |
| 172 | --load \ |
| 173 | --tag "$IMAGE_TAG" \ |
| 174 | --build-arg "TARGET=${TARGET}" \ |
| 175 | --file - . <<EOF |
| 176 | FROM ${RUST_IMAGE} |
| 177 | ARG TARGET |
| 178 | RUN cargo install topcoat-cli --version ${TOPCOAT_CLI_VERSION} --locked |
| 179 | WORKDIR /src |
| 180 | COPY . . |
| 181 | RUN set -eux; ${EXTRA_SETUP:-true}; \\ |
| 182 | topcoat asset bundle --release; \\ |
| 183 | mkdir -p /out; \\ |
| 184 | cp ${BIN_PATH} /out/steid; \\ |
| 185 | cp -r target/assets /out/assets |
| 186 | EOF |
| 187 | |
| 188 | # `docker create` + `docker cp` rather than a bind mount: the build ran on a |
| 189 | # possibly-emulated platform and this needs no write access to the host tree. |
| 190 | CONTAINER="$(docker create --platform "$PLATFORM" "$IMAGE_TAG" /bin/true)" |
| 191 | trap 'docker rm -f "$CONTAINER" >/dev/null 2>&1 || true' EXIT |
| 192 | docker cp "${CONTAINER}:/out/steid" "$STAGE/steid" |
| 193 | docker cp "${CONTAINER}:/out/assets" "$STAGE/assets" |
| 194 | docker rm -f "$CONTAINER" >/dev/null |
| 195 | trap - EXIT |
| 196 | fi |
| 197 | |
| 198 | chmod 755 "$STAGE/steid" |
| 199 | |
| 200 | # The README ships inside the tarball so an unpacked directory on a server is |
| 201 | # self-explanatory without network access. |
| 202 | cp README.md "$STAGE/README.md" |
| 203 | |
| 204 | # --- sanity checks ---------------------------------------------------------- |
| 205 | |
| 206 | # The single failure mode worth guarding: an artefact whose assets are missing or |
| 207 | # in the wrong place boots fine in CI and dies on the user's first request. |
| 208 | [ -f "$STAGE/assets/manifest.toml" ] || { |
| 209 | echo "release.sh: assets/manifest.toml is missing — was this built with" >&2 |
| 210 | echo " 'topcoat asset bundle' and not a bare 'cargo build'?" >&2 |
| 211 | exit 1 |
| 212 | } |
| 213 | |
| 214 | # --- package ---------------------------------------------------------------- |
| 215 | |
| 216 | TARBALL="${OUT_DIR}/${NAME}.tar.gz" |
| 217 | # `--no-xattrs` and COPYFILE_DISABLE because macOS's bsdtar otherwise stores |
| 218 | # Apple extended attributes, and GNU tar on the machine that extracts this then |
| 219 | # prints a warning line per file: "Ignoring unknown extended header keyword |
| 220 | # 'LIBARCHIVE.xattr.com.apple.provenance'". Harmless, and it makes a release look |
| 221 | # broken in the first thirty seconds a stranger spends with it. |
| 222 | COPYFILE_DISABLE=1 tar --no-xattrs -czf "$TARBALL" -C "$OUT_DIR" "$NAME" 2>/dev/null \ |
| 223 | || COPYFILE_DISABLE=1 tar -czf "$TARBALL" -C "$OUT_DIR" "$NAME" |
| 224 | |
| 225 | # A checksum file per tarball, which is what install.sh fetches and verifies. |
| 226 | # Written next to the tarball with a bare name inside it so `sha256sum -c` works |
| 227 | # from the download directory. |
| 228 | ( |
| 229 | cd "$OUT_DIR" |
| 230 | if command -v sha256sum >/dev/null 2>&1; then |
| 231 | sha256sum "${NAME}.tar.gz" > "${NAME}.tar.gz.sha256" |
| 232 | else |
| 233 | # macOS has shasum, not sha256sum. Same output format. |
| 234 | shasum -a 256 "${NAME}.tar.gz" > "${NAME}.tar.gz.sha256" |
| 235 | fi |
| 236 | ) |
| 237 | |
| 238 | echo |
| 239 | echo "release.sh: wrote" |
| 240 | echo " ${TARBALL}" |
| 241 | echo " ${TARBALL}.sha256" |
| 242 | echo |
| 243 | echo "Upload both to the release named v${VERSION}. install.sh expects exactly" |
| 244 | echo "these filenames." |