1 Commits

Author SHA1 Message Date
clawbot
b100814f8e build: unify the gate so root make check covers the backend (closes #16)
All checks were successful
check / check (push) Successful in 29s
Root `make check` only ever ran the frontend, so the "main is always
green" policy was satisfied vacuously: the Go backend could be entirely
broken and the root gate stayed green.

- The backend moves onto scripts-to-rule-them-all. Its test, lint, fmt,
  fmt-check, build, run and clean implementations now live in
  `backend/script/`, and `backend/Makefile` is thin shims. The backend
  is its own project (own module, README, LICENSE, linter config,
  Dockerfile stage), and `Dockerfile.backend` only copies `backend/`
  into its builder, so its scripts have to live under `backend/`.
- The root `script/test`, `script/lint`, `script/fmt` and
  `script/fmt-check` now run the frontend step and then the matching
  `backend/script/*` step, so `script/check` — and therefore the
  pre-commit hook — gates both halves. The frontend-only steps moved
  into `script/frontend-*` so nothing is duplicated.
- `script/bootstrap` now provisions the backend's toolchain as well,
  because widening the gate without widening bootstrap left the
  documented fresh-clone path (`make setup`) installing a pre-commit
  hook that rejected every commit with `golangci-lint: not found`. Go
  is reused when the installed version is new enough, otherwise it is
  installed from the official `go1.25.7` release archive; golangci-lint
  is installed at exactly `2.7.2`, the version `Dockerfile.backend`
  pins, so local findings match CI. Both come from a specific release
  archive whose sha256 is hardcoded here and verified before anything
  is unpacked — never an install script piped to a shell — and both are
  symlinked onto `PATH`, since nvm-style activation does not reach
  `make` or the git hook.
- `script/frontend-check` is the frontend half of the gate, exposed as
  the `frontend-check` target, for the frontend Dockerfile: its build
  stage is a node image with no Go toolchain. The backend half is gated
  by `Dockerfile.backend`, and `script/cibuild` builds both images, so
  the two Dockerfiles together still gate the whole repo. The
  `backend-check` target is the mirror of it. Both targets are named
  after the script they shim, like every other target.
- `script/cibuild` builds both images through one `build_image` helper,
  and the Gitea workflow's only build step is `script/cibuild`; the raw
  `docker build -f Dockerfile.backend .` is gone from the workflow.
  `script/docker` likewise builds and tags both images.
- `backend/Makefile`'s `hooks` target is removed. It wrote the same
  `.git/hooks/pre-commit` as `script/install-precommit`, so the two
  clobbered each other and the developer silently ended up gating on
  only one half of the repo. `script/install-precommit` is now the only
  installer, and the hook it writes runs the repo-wide `script/check`.
- `backend/Makefile`'s `docker` target is removed too: the backend image
  builds from the repo root with a root-level Dockerfile, so it belongs
  to the root `script/docker` and `script/cibuild` rather than to a
  backend script that would have to reach outside `backend/`.
- `backend/script/lint` verifies that `.golangci.yml` still matches its
  pinned sha256 before running the linter. Offline hash comparison, no
  network. The pin is marked provisional in the file: it is the config
  currently on `main`, and the comment names PR #31 and the canonical
  hash that must replace it when #31 lands.
- Every script locates the repo root with the mandated
  `$(cd "$(dirname "$0")/.." && pwd -P)` idiom, `cd`s there, and calls
  siblings as `"$ROOT/script/<name>"`; the `SCRIPT_DIR` variant is gone.

READMEs at the root and in `backend/` document every script, the
backend's Getting Started separates commands run from `backend/` from
those run at the repo root, and `TODO.md` records the change.
2026-08-09 06:34:04 +00:00
2 changed files with 37 additions and 181 deletions

View File

@@ -28,12 +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
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

View File

@@ -16,35 +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. It never writes outside $HOME. A per-repo bootstrap has no
# business writing to /usr/local/bin, a Homebrew prefix, or any
# other system-wide location shared with other users and with a
# package manager.
# 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"
@@ -54,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
@@ -86,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=""
@@ -213,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
@@ -306,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
}
@@ -367,18 +278,14 @@ 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"
}
ensure_go() {
@@ -464,62 +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.
verify_toolchain() {
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"
golangci_lint_ok || bad="$bad golangci-lint"
for t in gofmt node yarn; do
if missing "$t"; then bad="$bad $t"; fi
done
PATH="$saved_path"
export PATH
[ -z "$bad" ] && return 0
echo "bootstrap: the toolchain on your PATH cannot run the gate." >&2
for t in $bad; do
where="$(
export PATH="$verify_path"
command -v "$t" || echo "not found"
)"
echo " $t: $where" >&2
done
echo " Expected these to come from $BIN_DIR. Something earlier on" >&2
echo " your PATH is shadowing them, or PATH does not reach it." >&2
echo " Put $BIN_DIR first in PATH, or remove the conflicting tool," >&2
echo " then re-run bootstrap. Failing rather than leaving you a" >&2
echo " bootstrap that reports success and a \`make check\` that does" >&2
echo " 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
@@ -531,8 +394,6 @@ main() {
ensure_go
ensure_golangci_lint
verify_toolchain
echo "bootstrap complete"
}