Compare commits
1 Commits
fix/unify-
...
b100814f8e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b100814f8e |
12
README.md
12
README.md
@@ -37,13 +37,11 @@ broken. We provide:
|
||||
|
||||
- `script/bootstrap` — install all dependencies, assuming nothing is present:
|
||||
pinned node via nvm if needed, yarn via corepack,
|
||||
`yarn install --frozen-lockfile`, and the backend's toolchain — Go (an
|
||||
already-installed Go is reused only when its version falls inside the window
|
||||
the pinned golangci-lint can analyse; a newer Go is ignored, not preferred)
|
||||
and golangci-lint at the version `Dockerfile.backend` pins. Everything not
|
||||
installed by the system package manager comes from a hash-verified release
|
||||
archive and is symlinked into `~/.local/bin`, so `make check` works in a plain
|
||||
shell afterwards
|
||||
`yarn install --frozen-lockfile`, and the backend's toolchain — Go (reused if
|
||||
already new enough) and golangci-lint at the version `Dockerfile.backend`
|
||||
pins. Everything not installed by the system package manager comes from a
|
||||
hash-verified release archive and is symlinked onto `PATH`, so `make check`
|
||||
works in a plain shell afterwards
|
||||
- `script/setup` — make a fresh clone ready for development: bootstrap plus the
|
||||
git pre-commit hook
|
||||
- `script/projectname` — print the project name (used for the Docker image tags)
|
||||
|
||||
9
TODO.md
9
TODO.md
@@ -28,14 +28,7 @@ files, so merging it also closes most compliance gaps.
|
||||
pre-commit hook installer in `backend/Makefile` was removed, `script/cibuild`
|
||||
now builds both images as the workflow's only build step, and
|
||||
`script/bootstrap` provisions the backend toolchain (pinned, hash-verified Go
|
||||
and golangci-lint) so a fresh clone can pass the widened gate. Bootstrap
|
||||
matches the Go pin rather than treating it as a floor, because the pinned
|
||||
golangci-lint cannot analyse packages built by a newer Go; it links only into
|
||||
`~/.local/bin`, never a system-wide prefix, and refuses to replace anything it
|
||||
did not create; and it exits non-zero rather than reporting success when the
|
||||
tools on the caller's `PATH` are not the pinned ones — `gofmt` included, held
|
||||
to the same version agreement as `go` and relinked on every run so a deleted
|
||||
link cannot leave another Go's `gofmt` gating the repo
|
||||
and golangci-lint) so a fresh clone can pass the widened gate
|
||||
- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints, Makefile
|
||||
shims, README Entrypoints section
|
||||
- 2026-02-27: backend with buffered zstd-compressed report storage; CI workflow
|
||||
|
||||
284
script/bootstrap
284
script/bootstrap
@@ -5,11 +5,10 @@
|
||||
# or apk (detected in that order); assumes nothing is present. Node is
|
||||
# used directly if installed; otherwise it is installed at a pinned
|
||||
# version via nvm (installing nvm itself first, from a hash-verified
|
||||
# release archive, never curl | sh). Go is used directly only if its
|
||||
# version falls inside the pinned window described at GO_MAX_MINOR below
|
||||
# -- a newer Go is ignored, not preferred -- and golangci-lint is
|
||||
# installed at the exact pinned version; both come from hash-verified
|
||||
# official release archives, never an install script.
|
||||
# release archive, never curl | sh). Go is used directly if it is
|
||||
# already new enough, and golangci-lint is installed at the exact pinned
|
||||
# version; both come from hash-verified official release archives, never
|
||||
# an install script.
|
||||
#
|
||||
# The backend's toolchain is bootstrapped here because script/check runs
|
||||
# backend/script/test and backend/script/lint, so a machine that cannot
|
||||
@@ -17,38 +16,13 @@
|
||||
# pre-commit hook that script/setup installs.
|
||||
#
|
||||
# Anything installed outside the system package manager is symlinked
|
||||
# into ~/.local/bin, so a later `make check` in a plain shell finds it.
|
||||
# nvm only puts node on PATH for shells that source nvm.sh, which
|
||||
# neither make nor the git hook does.
|
||||
#
|
||||
# Three rules govern what this script is allowed to touch:
|
||||
#
|
||||
# 1. Everything it installs itself lands under $HOME, using $TMPDIR
|
||||
# only for scratch downloads it then deletes. The one exception is
|
||||
# the system package manager, which it shells out to for base
|
||||
# tooling (see pkg_install) and which owns those paths already. A
|
||||
# per-repo bootstrap has no business writing to /usr/local/bin, a
|
||||
# Homebrew prefix, or any other system-wide location behind that
|
||||
# package manager's back.
|
||||
# 2. It never replaces something it did not create. Only a symlink
|
||||
# that already points into one of its own managed directories is
|
||||
# overwritten; anything else is left alone and bootstrap exits
|
||||
# non-zero telling you what to remove.
|
||||
# 3. It never reports success while the tools a later `make check`
|
||||
# would pick up are not the ones it provisioned. If it cannot
|
||||
# guarantee the pinned toolchain wins on your PATH, it exits
|
||||
# non-zero rather than leaving you a green bootstrap and a broken
|
||||
# gate.
|
||||
# into a directory that is on PATH, so a later `make check` in a plain
|
||||
# shell finds it. nvm only puts node on PATH for shells that source
|
||||
# nvm.sh, which neither make nor the git hook does.
|
||||
set -eu
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||
|
||||
# The PATH as the caller had it, captured before ensure_bin_dir amends
|
||||
# it. verify_toolchain checks against this rather than against the PATH
|
||||
# this script builds for itself, so what it reports is what a later
|
||||
# `make check` in the user's own shell will actually resolve.
|
||||
ORIG_PATH="$PATH"
|
||||
|
||||
# Pinned versions, 2026-07-07
|
||||
NODE_VERSION="22.17.0"
|
||||
NVM_VERSION="0.40.3"
|
||||
@@ -58,27 +32,10 @@ YARN_VERSION="1.22.22"
|
||||
|
||||
# Go 1.25.7 (2026-08-09). This is the toolchain inside the pinned
|
||||
# golang:1.25-alpine builder of Dockerfile.backend, so a local build
|
||||
# uses the same compiler CI does.
|
||||
#
|
||||
# The Go pin is a compatibility constraint to match, not a floor to
|
||||
# clear. golangci-lint links go/types from the Go release it was built
|
||||
# with, and go/types refuses to load packages compiled by a newer Go:
|
||||
# with the pinned linter (built with go1.25.4) and a host Go 1.26,
|
||||
# `make check` dies with
|
||||
#
|
||||
# panic: file requires newer Go version go1.26
|
||||
# (application built with go1.25)
|
||||
#
|
||||
# So an already-installed go is reused only inside a window:
|
||||
# GO_MIN_VERSION is the floor from backend/go.mod, and GO_MAX_MINOR is
|
||||
# the major.minor of the Go the pinned golangci-lint was built with.
|
||||
# Anything outside that window is ignored and GO_VERSION is installed
|
||||
# instead. GO_MAX_MINOR is therefore coupled to GOLANGCI_LINT_VERSION
|
||||
# below and must be revisited whenever that pin moves; `golangci-lint
|
||||
# version` prints the "built with goX.Y.Z" it needs.
|
||||
# uses the same compiler CI does. GO_MIN_VERSION is the floor from
|
||||
# backend/go.mod; an already-installed go at or above it is used as is.
|
||||
GO_VERSION="1.25.7"
|
||||
GO_MIN_VERSION="1.25.5"
|
||||
GO_MAX_MINOR="1.25"
|
||||
|
||||
# golangci-lint 2.7.2 (2026-08-09). MUST stay equal to the golangci-lint
|
||||
# pinned in Dockerfile.backend (currently commit
|
||||
@@ -90,16 +47,12 @@ GO_MAX_MINOR="1.25"
|
||||
# v2.12.2, commit c0d3ddc9cf3faa61a4e378e879ece580256d76e5. When that
|
||||
# lands, GOLANGCI_LINT_VERSION and every hash in golangci_lint_sha256()
|
||||
# below must be updated to the v2.12.2 release archives in the same
|
||||
# commit, or local and CI will disagree. GO_MAX_MINOR must move with
|
||||
# it, to the major.minor that release reports as "built with".
|
||||
# commit, or local and CI will disagree.
|
||||
GOLANGCI_LINT_VERSION="2.7.2"
|
||||
|
||||
# Where hash-verified archives are unpacked. Version-scoped, so bumping
|
||||
# a pin installs alongside the old copy instead of half-overwriting it.
|
||||
# Filled in by main() from script/projectname. TOOLCHAIN is also the
|
||||
# ownership boundary used by link_bin: a symlink pointing inside it is
|
||||
# one this script created and may replace.
|
||||
TOOLCHAIN=""
|
||||
# Filled in by main() from script/projectname.
|
||||
GO_DIR=""
|
||||
GOLANGCI_LINT_DIR=""
|
||||
|
||||
@@ -217,67 +170,33 @@ ver_ge() {
|
||||
}'
|
||||
}
|
||||
|
||||
# ensure_bin_dir: the directory provisioned tools are linked into. It is
|
||||
# always ~/.local/bin: per-user, never a system-wide or package-manager
|
||||
# prefix. It is put at the front of PATH for the rest of this run, and
|
||||
# reported if the caller's own PATH did not already contain it.
|
||||
# ensure_bin_dir: pick the directory provisioned tools are linked into.
|
||||
# /usr/local/bin when writable (root, or a Homebrew prefix), otherwise
|
||||
# ~/.local/bin, which is prepended to PATH for the rest of this run and
|
||||
# reported so the user can add it permanently.
|
||||
ensure_bin_dir() {
|
||||
[ -n "$BIN_DIR" ] && return 0
|
||||
if [ -d /usr/local/bin ] && [ -w /usr/local/bin ]; then
|
||||
BIN_DIR="/usr/local/bin"
|
||||
else
|
||||
BIN_DIR="$HOME/.local/bin"
|
||||
mkdir -p "$BIN_DIR"
|
||||
case "$PATH" in
|
||||
"$BIN_DIR":*) ;;
|
||||
fi
|
||||
case ":$PATH:" in
|
||||
*":$BIN_DIR:"*) ;;
|
||||
*)
|
||||
PATH="$BIN_DIR:$PATH"
|
||||
export PATH
|
||||
echo "bootstrap: add $BIN_DIR to your PATH" >&2
|
||||
;;
|
||||
esac
|
||||
case ":$ORIG_PATH:" in
|
||||
*":$BIN_DIR:"*) ;;
|
||||
*)
|
||||
echo "bootstrap: add $BIN_DIR to the front of your PATH, e.g." >&2
|
||||
echo " export PATH=\"\$HOME/.local/bin:\$PATH\"" >&2
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# owned_path <path>: true when <path> lies inside a directory this
|
||||
# script provisions, i.e. a link to it is one this script created and
|
||||
# may replace. Everything else belongs to the user or to a package
|
||||
# manager and is never touched.
|
||||
owned_path() {
|
||||
case "$1" in
|
||||
"$TOOLCHAIN"/*) return 0 ;;
|
||||
"$HOME"/.nvm/*) return 0 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# refuse_clobber <path> <what>: report that <path> is not ours and stop.
|
||||
refuse_clobber() {
|
||||
echo "bootstrap: $1 already exists and $2." >&2
|
||||
echo " Refusing to replace something this script did not create." >&2
|
||||
echo " Remove or rename it and re-run bootstrap." >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# link_bin <target> <name>: idempotently expose one provisioned binary
|
||||
# on PATH. Only an existing symlink into one of our own directories is
|
||||
# replaced; a regular file, a directory, or a symlink pointing anywhere
|
||||
# else is left intact and bootstrap fails.
|
||||
# on PATH.
|
||||
link_bin() {
|
||||
ensure_bin_dir
|
||||
link="$BIN_DIR/$2"
|
||||
if [ -L "$link" ]; then
|
||||
existing="$(readlink "$link")"
|
||||
if ! owned_path "$existing"; then
|
||||
refuse_clobber "$link" \
|
||||
"is a symlink to $existing, outside this repo's toolchain"
|
||||
fi
|
||||
elif [ -e "$link" ]; then
|
||||
refuse_clobber "$link" "is not a symlink"
|
||||
fi
|
||||
ln -sfn "$1" "$link"
|
||||
ln -sfn "$1" "$BIN_DIR/$2"
|
||||
}
|
||||
|
||||
# nvm is a bash script; run a command in a bash with nvm loaded
|
||||
@@ -310,31 +229,19 @@ ensure_node() {
|
||||
done
|
||||
}
|
||||
|
||||
# ensure_yarn: yarn comes from corepack. Left to itself, `corepack
|
||||
# enable` writes its shims next to the corepack binary it resolved, and
|
||||
# it writes four of them (yarn, yarnpkg, pnpm, pnpx), not the one asked
|
||||
# for. --install-directory keeps all four inside this repo's own
|
||||
# toolchain directory, and only yarn is then linked onto PATH. The
|
||||
# no-corepack fallback likewise installs into a per-user npm prefix
|
||||
# under the toolchain directory instead of npm's global one. Nothing
|
||||
# here writes outside $HOME.
|
||||
ensure_yarn() {
|
||||
if ! missing yarn; then return 0; fi
|
||||
yarn_bin="$TOOLCHAIN/corepack-shims"
|
||||
mkdir -p "$yarn_bin"
|
||||
if ! missing corepack; then
|
||||
corepack enable --install-directory "$yarn_bin"
|
||||
corepack enable
|
||||
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 \"$yarn_bin\" && \
|
||||
nvm_sh "nvm use $NODE_VERSION >/dev/null && corepack enable && \
|
||||
corepack prepare yarn@$YARN_VERSION --activate"
|
||||
else
|
||||
yarn_bin="$TOOLCHAIN/npm-global/bin"
|
||||
npm install -g --prefix "$TOOLCHAIN/npm-global" "yarn@$YARN_VERSION"
|
||||
npm install -g "yarn@$YARN_VERSION"
|
||||
fi
|
||||
if [ -e "$yarn_bin/yarn" ]; then
|
||||
link_bin "$yarn_bin/yarn" yarn
|
||||
if [ -n "$NODE_BIN" ] && [ -e "$NODE_BIN/yarn" ]; then
|
||||
link_bin "$NODE_BIN/yarn" yarn
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -371,46 +278,18 @@ go_sha256() {
|
||||
esac
|
||||
}
|
||||
|
||||
# go_ok: an already-installed go is acceptable only inside the window
|
||||
# described at GO_MAX_MINOR: at least GO_MIN_VERSION, and no newer in
|
||||
# major.minor than the Go the pinned golangci-lint was built with. A
|
||||
# newer host Go is not "good enough", it makes `make check` panic, so it
|
||||
# is treated exactly like a missing one.
|
||||
# go_ok: an already-installed go is acceptable if it is at least
|
||||
# GO_MIN_VERSION, mirroring how node is used when already present.
|
||||
go_ok() {
|
||||
if missing go; then return 1; fi
|
||||
have="$(go version 2>/dev/null | awk '{print $3}')"
|
||||
have="${have#go}"
|
||||
[ -n "$have" ] || return 1
|
||||
ver_ge "$have" "$GO_MIN_VERSION" || return 1
|
||||
ver_ge "$GO_MAX_MINOR" "$(echo "$have" | cut -d. -f1,2)"
|
||||
ver_ge "$have" "$GO_MIN_VERSION"
|
||||
}
|
||||
|
||||
# gofmt_ok: gofmt is a gate tool -- backend/script/fmt-check runs it --
|
||||
# and its output is not guaranteed byte-identical across Go releases, so
|
||||
# a gofmt from a different release than the go that compiles the code is
|
||||
# treated exactly like a missing one, the same way a mismatched
|
||||
# golangci-lint is. `go version <file>` prints the toolchain a Go binary
|
||||
# was built with, so this compares the gofmt that resolves on PATH
|
||||
# against the go that resolves on PATH, without depending on where
|
||||
# either one lives. Anything it cannot read -- no go to ask, gofmt
|
||||
# absent, not a Go binary -- fails closed.
|
||||
gofmt_ok() {
|
||||
if missing go; then return 1; fi
|
||||
if missing gofmt; then return 1; fi
|
||||
go_have="$(go version 2>/dev/null | awk '{print $3}')"
|
||||
[ -n "$go_have" ] || return 1
|
||||
fmt_have="$(go version "$(command -v gofmt)" 2>/dev/null | awk '{print $NF}')"
|
||||
[ "$fmt_have" = "$go_have" ]
|
||||
}
|
||||
|
||||
# ensure_go: reuse the host toolchain only when the go on PATH is inside
|
||||
# the window AND a gofmt from that same release is on PATH with it. Both
|
||||
# link_bin calls sit outside that early return, so whenever the pinned
|
||||
# toolchain is the one in use they run on every bootstrap, not only on
|
||||
# the run that unpacked the archive: a deleted or never-created gofmt
|
||||
# link is restored rather than silently left to some other Go's gofmt.
|
||||
ensure_go() {
|
||||
if go_ok && gofmt_ok; then return 0; fi
|
||||
if go_ok; then return 0; fi
|
||||
if [ ! -x "$GO_DIR/bin/go" ]; then
|
||||
plat="$(platform)"
|
||||
tmp="$(mktemp -d)"
|
||||
@@ -492,101 +371,18 @@ ensure_golangci_lint() {
|
||||
rm -rf "$tmp"
|
||||
fi
|
||||
link_bin "$GOLANGCI_LINT_DIR/golangci-lint" golangci-lint
|
||||
}
|
||||
|
||||
# verify_toolchain: bootstrap must not exit 0 while the tools the gate
|
||||
# will actually run are not the provisioned ones. Everything above only
|
||||
# guarantees the right tools exist and are linked into $BIN_DIR; if
|
||||
# something earlier on the caller's PATH shadows them, `make check` --
|
||||
# and the pre-commit hook script/setup installs -- still break, and a
|
||||
# warning buried in a long bootstrap log is not enough. So the checks
|
||||
# re-run against the PATH the caller will have (theirs, plus $BIN_DIR at
|
||||
# the front if bootstrap had to ask for it), and a failure is fatal.
|
||||
#
|
||||
# Every gate tool that has a version constraint is checked with the same
|
||||
# predicate its install used -- go_ok, gofmt_ok, golangci_lint_ok -- not
|
||||
# with a bare presence test, because a wrong-version gate tool produces
|
||||
# different results from the one CI runs, which is the failure this
|
||||
# function exists to prevent. node and yarn have no pinned version to
|
||||
# disagree about, so presence is the whole constraint for them.
|
||||
verify_toolchain() {
|
||||
# BIN_DIR is only set once something needed linking, but the remedy
|
||||
# text must name a real directory in every reachable state, so fall
|
||||
# back to the one ensure_bin_dir would have chosen.
|
||||
bin_dir="${BIN_DIR:-$HOME/.local/bin}"
|
||||
|
||||
# Model the PATH the caller will actually have: their own, plus
|
||||
# $BIN_DIR at the front only if bootstrap linked something there and
|
||||
# therefore told them to add it.
|
||||
verify_path="$ORIG_PATH"
|
||||
if [ -n "$BIN_DIR" ]; then
|
||||
case ":$ORIG_PATH:" in
|
||||
*":$BIN_DIR:"*) ;;
|
||||
*) verify_path="$BIN_DIR:$ORIG_PATH" ;;
|
||||
esac
|
||||
if ! golangci_lint_ok; then
|
||||
echo "bootstrap: a different golangci-lint precedes $BIN_DIR on your" >&2
|
||||
echo " PATH; local lint findings may not match what CI gates on" >&2
|
||||
fi
|
||||
|
||||
saved_path="$PATH"
|
||||
PATH="$verify_path"
|
||||
export PATH
|
||||
bad=""
|
||||
go_ok || bad="$bad go"
|
||||
gofmt_ok || bad="$bad gofmt"
|
||||
golangci_lint_ok || bad="$bad golangci-lint"
|
||||
for t in node yarn; do
|
||||
if missing "$t"; then bad="$bad $t"; fi
|
||||
done
|
||||
PATH="$saved_path"
|
||||
export PATH
|
||||
|
||||
[ -z "$bad" ] && return 0
|
||||
|
||||
# Two different faults land here and they need different remedies: a
|
||||
# tool that resolves but is the wrong build is being shadowed, and
|
||||
# telling the user to fix PATH is right; a tool that does not resolve
|
||||
# at all is not being shadowed by anything, and saying so would send
|
||||
# them hunting for a conflict that does not exist.
|
||||
echo "bootstrap: the toolchain on your PATH cannot run the gate." >&2
|
||||
wrong=""
|
||||
absent=""
|
||||
for t in $bad; do
|
||||
where="$(
|
||||
export PATH="$verify_path"
|
||||
command -v "$t" 2>/dev/null || true
|
||||
)"
|
||||
if [ -n "$where" ]; then
|
||||
echo " $t: $where (wrong version)" >&2
|
||||
wrong="$wrong $t"
|
||||
else
|
||||
echo " $t: not found" >&2
|
||||
absent="$absent $t"
|
||||
fi
|
||||
done
|
||||
echo " The pinned toolchain is linked into $bin_dir." >&2
|
||||
if [ -n "$wrong" ]; then
|
||||
echo " The tools shown with a path resolve to a build this" >&2
|
||||
echo " script did not provision: something earlier on your PATH" >&2
|
||||
echo " shadows $bin_dir. Put $bin_dir first in" >&2
|
||||
echo " PATH, or remove the conflicting tool, then re-run." >&2
|
||||
fi
|
||||
if [ -n "$absent" ]; then
|
||||
echo " The tools shown as not found are on no directory of your" >&2
|
||||
echo " PATH at all, so nothing is shadowing them. Add $bin_dir" >&2
|
||||
echo " to PATH and re-run; if they are still not found after" >&2
|
||||
echo " that, bootstrap failed to install them and that is a bug" >&2
|
||||
echo " in this script, not in your environment." >&2
|
||||
fi
|
||||
echo " Failing rather than leaving you a bootstrap that reports" >&2
|
||||
echo " success and a \`make check\` that does not run." >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
main() {
|
||||
cd "$ROOT"
|
||||
|
||||
TOOLCHAIN="$HOME/.local/share/$("$ROOT/script/projectname")/toolchain"
|
||||
GO_DIR="$TOOLCHAIN/go-$GO_VERSION"
|
||||
GOLANGCI_LINT_DIR="$TOOLCHAIN/golangci-lint-$GOLANGCI_LINT_VERSION"
|
||||
toolchain="$HOME/.local/share/$("$ROOT/script/projectname")/toolchain"
|
||||
GO_DIR="$toolchain/go-$GO_VERSION"
|
||||
GOLANGCI_LINT_DIR="$toolchain/golangci-lint-$GOLANGCI_LINT_VERSION"
|
||||
|
||||
if missing make; then pkg_install gnumake make make make; fi
|
||||
if missing git; then pkg_install git git git git; fi
|
||||
@@ -598,8 +394,6 @@ main() {
|
||||
ensure_go
|
||||
ensure_golangci_lint
|
||||
|
||||
verify_toolchain
|
||||
|
||||
echo "bootstrap complete"
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user