check / check (push) Waiting to run
The shared files are the sneak/prompts copies at dd4027b, plus this repository's own entries. make lint and make test each build one Dockerfile phase without the cache, both covering the frontend; the builder stage waits on both and takes its version from git describe unless VERSION is given. The test phase keeps Go's module and build caches in memory, out of the image make test tags. golangci-lint moves to v2.14.0 with the new .golangci.yml; one test spells X-Request-ID as canonicalheader asks. prettier formats only JavaScript, CSS, HTML and Markdown, so .golangci.yml stays as fetched. script/fmt and script/fmt-check put ~/.local/bin on PATH. script/bootstrap keeps a Go only if it is exactly GO_VERSION, and re-checks the go on PATH after installing. Model: opus-5-5
302 lines
11 KiB
Bash
Executable File
302 lines
11 KiB
Bash
Executable File
#!/bin/sh
|
|
# script/bootstrap: install all dependencies needed to build and develop
|
|
# this repo. Idempotent: every install is guarded by a check so already
|
|
# installed tools are skipped. Base tooling comes from nix, apt, brew,
|
|
# or apk (detected in that order); assumes nothing is present. Node is
|
|
# used directly if it is at least NODE_MIN_VERSION; otherwise it is
|
|
# installed at a pinned version via nvm (installing nvm itself first,
|
|
# from a hash-verified release archive, never curl | sh). Go, with its
|
|
# gofmt, is used directly only if it is exactly GO_VERSION; otherwise
|
|
# that release is installed from its hash-verified archive.
|
|
#
|
|
# What this script installs outside the system package manager lives
|
|
# under $HOME and is linked into ~/.local/bin, where make and the git
|
|
# hook find it once that directory is on PATH. Nothing in ~/.local/bin
|
|
# that this script did not create is ever replaced.
|
|
#
|
|
# golangci-lint is not installed: make lint runs it in Docker, as make
|
|
# test runs the tests, and this script does not install Docker either.
|
|
#
|
|
# Unlike the org model: Go and gcc for backend/, a newer node for eslint.
|
|
set -eu
|
|
|
|
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
|
|
|
# Pinned versions, 2026-07-06
|
|
NODE_VERSION="22.17.0"
|
|
# The oldest node the frontend's dependencies accept: the "engines"
|
|
# field of eslint 10.12.0, the most demanding of them, asks for 22.13.0
|
|
# or newer, 2026-10-03. An older installed node is not used.
|
|
NODE_MIN_VERSION="22.13.0"
|
|
NVM_VERSION="0.40.3"
|
|
# sha256 of https://github.com/nvm-sh/nvm/archive/refs/tags/v0.40.3.tar.gz
|
|
NVM_SHA256="5f4d6aaa04a177dc93c985e31dbc411ab6b8c6e1e21d8015dbc1372625fcd1d0"
|
|
YARN_VERSION="1.22.22"
|
|
# The Go inside the golang:1.25-alpine image Dockerfile builds the
|
|
# backend with, 2026-08-09. The archive hashes are in ensure_go.
|
|
GO_VERSION="1.25.7"
|
|
|
|
BIN_DIR="$HOME/.local/bin"
|
|
TOOLCHAIN="$HOME/.local/share/$("$ROOT/script/projectname")/toolchain"
|
|
|
|
PKGMGR=""
|
|
SUDO=""
|
|
APT_UPDATED=""
|
|
|
|
detect_pkgmgr() {
|
|
[ -n "$PKGMGR" ] && return 0
|
|
if command -v nix-env >/dev/null 2>&1; then
|
|
PKGMGR="nix"
|
|
elif command -v apt-get >/dev/null 2>&1; then
|
|
PKGMGR="apt"
|
|
elif command -v brew >/dev/null 2>&1; then
|
|
PKGMGR="brew"
|
|
elif command -v apk >/dev/null 2>&1; then
|
|
PKGMGR="apk"
|
|
else
|
|
echo "bootstrap: no supported package manager (nix, apt, brew, apk)" >&2
|
|
exit 1
|
|
fi
|
|
if [ "$PKGMGR" = "apt" ]; then
|
|
export DEBIAN_FRONTEND=noninteractive
|
|
if [ "$(id -u)" != "0" ]; then
|
|
SUDO="sudo"
|
|
fi
|
|
fi
|
|
}
|
|
|
|
# pkg_install <nix-attr> <apt-pkgs> <brew-formula> <apk-pkgs>: the apt
|
|
# and apk arguments may each list several packages, separated by spaces.
|
|
pkg_install() {
|
|
detect_pkgmgr
|
|
case "$PKGMGR" in
|
|
nix) nix-env -iA "nixpkgs.$1" ;;
|
|
apt)
|
|
if [ -z "$APT_UPDATED" ]; then
|
|
$SUDO env DEBIAN_FRONTEND=noninteractive apt-get update
|
|
APT_UPDATED=1
|
|
fi
|
|
$SUDO env DEBIAN_FRONTEND=noninteractive apt-get install -y $2
|
|
;;
|
|
brew) brew install "$3" ;;
|
|
apk) apk add --no-cache $4 ;;
|
|
esac
|
|
}
|
|
|
|
missing() {
|
|
! command -v "$1" >/dev/null 2>&1
|
|
}
|
|
|
|
# verify_sha256 <file> <expected-hash>
|
|
verify_sha256() {
|
|
if command -v sha256sum >/dev/null 2>&1; then
|
|
actual="$(sha256sum "$1" | cut -d' ' -f1)"
|
|
else
|
|
actual="$(shasum -a 256 "$1" | cut -d' ' -f1)"
|
|
fi
|
|
if [ "$actual" != "$2" ]; then
|
|
echo "bootstrap: sha256 mismatch for $1" >&2
|
|
echo " expected: $2" >&2
|
|
echo " actual: $actual" >&2
|
|
exit 1
|
|
fi
|
|
}
|
|
|
|
# link_bin <target> <name>: make an installed tool reachable as
|
|
# $BIN_DIR/<name>. Only a symlink this script made, one pointing into
|
|
# $TOOLCHAIN or ~/.nvm, is ever replaced; if anything else is already
|
|
# there, bootstrap stops.
|
|
link_bin() {
|
|
link="$BIN_DIR/$2"
|
|
if [ -L "$link" ] || [ -e "$link" ]; then
|
|
case "$(readlink "$link" || true)" in
|
|
"$TOOLCHAIN"/* | "$HOME"/.nvm/*) ;;
|
|
*)
|
|
echo "bootstrap: $link was not created by this script;" >&2
|
|
echo " remove or rename it, then re-run bootstrap" >&2
|
|
exit 1
|
|
;;
|
|
esac
|
|
fi
|
|
mkdir -p "$BIN_DIR"
|
|
ln -sf "$1" "$link"
|
|
}
|
|
|
|
# nvm is a bash script; run a command in a bash with nvm loaded
|
|
nvm_sh() {
|
|
bash -c ". \"\$HOME/.nvm/nvm.sh\" && $*"
|
|
}
|
|
|
|
ensure_nvm() {
|
|
[ -s "$HOME/.nvm/nvm.sh" ] && return 0
|
|
# nvm prerequisites; nvm itself requires bash
|
|
if missing bash; then pkg_install bash bash bash bash; fi
|
|
if missing curl; then pkg_install curl curl curl curl; fi
|
|
if missing git; then pkg_install git git git git; fi
|
|
tmp="$(mktemp -d)"
|
|
curl -fsSL -o "$tmp/nvm.tar.gz" \
|
|
"https://github.com/nvm-sh/nvm/archive/refs/tags/v${NVM_VERSION}.tar.gz"
|
|
verify_sha256 "$tmp/nvm.tar.gz" "$NVM_SHA256"
|
|
mkdir -p "$HOME/.nvm"
|
|
tar -xzf "$tmp/nvm.tar.gz" -C "$HOME/.nvm" --strip-components=1
|
|
rm -rf "$tmp"
|
|
}
|
|
|
|
# node_ok: the node on PATH is at least NODE_MIN_VERSION. node itself
|
|
# compares the two: major, then minor, then patch.
|
|
node_ok() {
|
|
if missing node; then return 1; fi
|
|
node -e '
|
|
const have = process.versions.node.split(".").map(Number);
|
|
const want = process.argv[1].split(".").map(Number);
|
|
for (let i = 0; i < 3; i++) {
|
|
if (have[i] !== want[i]) process.exit(have[i] > want[i] ? 0 : 1);
|
|
}
|
|
' "$NODE_MIN_VERSION"
|
|
}
|
|
|
|
# ensure_node: unless node_ok, install NODE_VERSION and link its node.
|
|
ensure_node() {
|
|
if node_ok; then return 0; fi
|
|
ensure_nvm
|
|
nvm_sh "nvm install $NODE_VERSION"
|
|
link_bin "$HOME/.nvm/versions/node/v$NODE_VERSION/bin/node" node
|
|
}
|
|
|
|
# ensure_yarn: corepack writes its shims (pnpm and yarnpkg as well as
|
|
# yarn) into $TOOLCHAIN rather than next to itself, and the npm fallback
|
|
# installs there too; only yarn is linked.
|
|
ensure_yarn() {
|
|
if ! missing yarn; then return 0; fi
|
|
shims="$TOOLCHAIN/corepack-shims"
|
|
mkdir -p "$shims"
|
|
if ! missing corepack; then
|
|
corepack enable --install-directory "$shims"
|
|
corepack prepare "yarn@$YARN_VERSION" --activate
|
|
elif [ -s "$HOME/.nvm/nvm.sh" ]; then
|
|
nvm_sh "nvm use $NODE_VERSION >/dev/null && \
|
|
corepack enable --install-directory \"$shims\" && \
|
|
corepack prepare yarn@$YARN_VERSION --activate"
|
|
else
|
|
npm install -g --prefix "$TOOLCHAIN/npm-global" "yarn@$YARN_VERSION"
|
|
shims="$TOOLCHAIN/npm-global/bin"
|
|
fi
|
|
link_bin "$shims/yarn" yarn
|
|
}
|
|
|
|
# go_ok: the go on PATH has its gofmt beside it (a Go release ships the
|
|
# two together) and go version reports exactly GO_VERSION, compared over
|
|
# the whole version, not a prefix of it. A go that fails or prints
|
|
# anything else does not pass. GOTOOLCHAIN=local makes go report itself
|
|
# rather than a toolchain it would fetch.
|
|
go_ok() {
|
|
if missing go; then return 1; fi
|
|
[ -x "$(dirname "$(command -v go)")/gofmt" ] || return 1
|
|
version="$(GOTOOLCHAIN=local go version 2>/dev/null)" || return 1
|
|
case "$version" in
|
|
"go version go$GO_VERSION "*) return 0 ;;
|
|
*) return 1 ;;
|
|
esac
|
|
}
|
|
|
|
# ensure_go: unless go_ok, install GO_VERSION and link its go and gofmt.
|
|
# They are linked on every run that needs them, so a deleted link is put
|
|
# back, and the archive is unpacked again if either binary is missing.
|
|
# Afterwards go is looked up through PATH again and must pass go_ok, so
|
|
# another go that hides the link stops bootstrap.
|
|
ensure_go() {
|
|
if go_ok; then
|
|
echo "bootstrap: using $(GOTOOLCHAIN=local go version)"
|
|
return 0
|
|
fi
|
|
go_dir="$TOOLCHAIN/go-$GO_VERSION"
|
|
if [ ! -x "$go_dir/bin/go" ] || [ ! -x "$go_dir/bin/gofmt" ]; then
|
|
# sha256 of each archive, from https://go.dev/dl/?mode=json
|
|
case "$(uname -s)-$(uname -m)" in
|
|
Linux-x86_64)
|
|
plat="linux-amd64"
|
|
sha="12e6d6a191091ae27dc31f6efc630e3a3b8ba409baf3573d955b196fdf086005"
|
|
;;
|
|
Linux-aarch64)
|
|
plat="linux-arm64"
|
|
sha="ba611a53534135a81067240eff9508cd7e256c560edd5d8c2fef54f083c07129"
|
|
;;
|
|
Darwin-x86_64)
|
|
plat="darwin-amd64"
|
|
sha="bf5050a2152f4053837b886e8d9640c829dbacbc3370f913351eb0904cb706f5"
|
|
;;
|
|
Darwin-arm64)
|
|
plat="darwin-arm64"
|
|
sha="ff18369ffad05c57d5bed888b660b31385f3c913670a83ef557cdfd98ea9ae1b"
|
|
;;
|
|
*)
|
|
echo "bootstrap: no pinned Go release for this platform" >&2
|
|
exit 1
|
|
;;
|
|
esac
|
|
if missing curl; then pkg_install curl curl curl curl; fi
|
|
mkdir -p "$TOOLCHAIN"
|
|
curl -fsSL -o "$go_dir.tar.gz" \
|
|
"https://go.dev/dl/go$GO_VERSION.$plat.tar.gz"
|
|
verify_sha256 "$go_dir.tar.gz" "$sha"
|
|
# Unpacked beside its final place and then moved there, so an
|
|
# interrupted run never leaves a partial Go that looks complete.
|
|
rm -rf "$go_dir.partial"
|
|
mkdir "$go_dir.partial"
|
|
tar -xzf "$go_dir.tar.gz" -C "$go_dir.partial" --strip-components=1
|
|
rm -rf "$go_dir" "$go_dir.tar.gz"
|
|
mv "$go_dir.partial" "$go_dir"
|
|
fi
|
|
link_bin "$go_dir/bin/go" go
|
|
link_bin "$go_dir/bin/gofmt" gofmt
|
|
hash -r
|
|
if ! go_ok; then
|
|
echo "bootstrap: after installing go $GO_VERSION in $go_dir," >&2
|
|
echo " the go on PATH, $(command -v go), is not it or has no gofmt" >&2
|
|
exit 1
|
|
fi
|
|
echo "bootstrap: installed $(GOTOOLCHAIN=local go version)"
|
|
}
|
|
|
|
main() {
|
|
cd "$ROOT"
|
|
|
|
# Tools linked on an earlier run count as installed, and tools linked
|
|
# on this run are found by the steps after it.
|
|
path_hint=""
|
|
case ":$PATH:" in
|
|
*":$BIN_DIR:"*) ;;
|
|
*) path_hint=yes ;;
|
|
esac
|
|
PATH="$BIN_DIR:$PATH"
|
|
|
|
if missing make; then pkg_install gnumake make make make; fi
|
|
if missing git; then pkg_install git git git git; fi
|
|
# The race detector in backend/'s make test needs cgo, which Go turns
|
|
# on only when it finds its C compiler, gcc on Linux. apt and apk
|
|
# ship the C library headers apart from gcc.
|
|
if missing gcc; then
|
|
pkg_install gcc "gcc libc6-dev" gcc "gcc musl-dev"
|
|
fi
|
|
|
|
ensure_node
|
|
ensure_yarn
|
|
yarn install --frozen-lockfile
|
|
|
|
ensure_go
|
|
(cd "$ROOT/backend" && go mod download)
|
|
|
|
if missing docker; then
|
|
echo "bootstrap: docker not found; make test and make lint, and so" >&2
|
|
echo " make check and the pre-commit hook, need it" >&2
|
|
fi
|
|
if [ -n "$path_hint" ] && [ -d "$BIN_DIR" ]; then
|
|
echo "bootstrap: add $BIN_DIR to the front of your PATH, e.g." >&2
|
|
echo " export PATH=\"\$HOME/.local/bin:\$PATH\"" >&2
|
|
fi
|
|
|
|
echo "bootstrap complete"
|
|
}
|
|
|
|
main "$@"
|