# spec.sr.ht — build scaffolding (compare.sr.ht / sourcehut-dolt style).
#
# One Go binary plus a brant migration wrapper, both under cmd/. The CSS is
# built from the shared sourcehut scss partials with sassc; there is no
# frontend bundle — the prose differ renders server-side.
SERVICE=spec.sr.ht
BIN=specsrht
MIGRATE_BIN=specsrht-migrate
PREFIX?=/usr/local
BINDIR?=$(PREFIX)/bin
# ASSETS is the RUNTIME lookup root: specsrht-migrate resolves migrations and
# the schema under [sr.ht]assets (default /usr/share/sourcehut), NOT under
# PREFIX. Deriving these from PREFIX instead would install to
# /usr/local/share/... on a default build while the binary kept looking in
# /usr/share/sourcehut — the two would only agree at PREFIX=/usr. Keep every
# installed data path anchored to ASSETS so a default `make install` works.
ASSETS?=/usr/share/sourcehut
MIGRATIONDIR?=$(ASSETS)/migrations/$(SERVICE)
SCHEMAFILE?=$(ASSETS)/$(SERVICE).sql
# INSTALL is a variable because the `install -D` of `install-files` below —
# create the leading directories, then copy — is GNU coreutils, which is what
# the Alpine builders of this family have and what the sibling Makefiles
# assume. BSD install has no -D and fails on the first missing directory, so on
# a machine with the GNU tools under their g-prefix,
# `make INSTALL=ginstall install-files` is the same command.
INSTALL?=install
SASSC?=sassc
SASSC_INCLUDE=-I$(ASSETS)/scss
# The one stylesheet the binary embeds. The hash in the name is the
# cache-busting version — web/ globs for it and serves it — which is why the
# glob has to match exactly one file, and why check-css counts rather than asks.
CSS=web/static/main.min.*.css
all: build
# Compile the binaries. Both targets are .PHONY: Go decides staleness itself,
# and a real file target would never rebuild after a source edit. The cost is
# that anything depending on them recompiles — which is why `install` is split
# in two below.
#
# The `[ -d ./cmd/... ]` guards these targets carried during the build-out are
# gone. They were written so a tree without cmd/ would not fail its default
# target; what they buy now is a `make build` that prints "skip" and exits 0
# with no binary, so a deleted or renamed cmd/ directory reads as a green build
# that packages nothing. A missing package is a `go build` error naming it.
build: $(BIN) $(MIGRATE_BIN)
$(BIN):
go build -o $@ ./cmd/$(BIN)
$(MIGRATE_BIN):
go build -o $@ ./cmd/$(MIGRATE_BIN)
# TEST_TIMEOUT is the per-package ceiling handed to `go test`, and it is named
# here rather than left to the toolchain because the toolchain's default is ten
# minutes, it is silent about being a default, and what it produces when a
# package walks into it is `panic: test timed out after 10m0s` over a few
# hundred lines of goroutine dump naming whichever test happened to be
# mid-flight — neither a hang nor a deadlock, and a Postgres schema left behind
# because a panic runs no t.Cleanup.
#
# Measured on this tree with go1.26.5 against a local Postgres, the whole suite
# is under two minutes and its slowest package (service/) is 20 s, so twenty
# minutes is not a bound anything is near. It is the family's number, and it is
# here so that the builder — slower, cold-cached, and running the database
# suites this laptop can also run — is nowhere near it either.
TEST_TIMEOUT?=20m
# PKG is what to run, a variable so `make test PKG=./db/` is the same command
# with the -timeout still on it.
PKG?=./...
test:
go test -timeout $(TEST_TIMEOUT) $(PKG)
# The coverage profile cov.sr.ht is fed, and the one place `go test` grows the
# two flags that decide what that profile means. -covermode=atomic records real
# hit counts rather than a set/unset bit, which is what a trend across commits
# is read off; COVERPROFILE is a variable because CI writes it to $HOME (where
# `artifacts:` looks) while a checkout wants it in the checkout.
#
# It runs exactly the suites `test` runs, so CI runs one of the two and not
# both.
COVERPROFILE?=cover.out
cover:
go test -timeout $(TEST_TIMEOUT) -covermode=atomic -coverprofile="$(COVERPROFILE)" $(PKG)
go tool cover -func="$(COVERPROFILE)" | tail -1
# BENCH_COUNT is `go test -count` for the `bench` target. bench.sr.ht marks a
# point measured under six repetitions "low n" — a point's confidence interval
# only becomes finite at six — so ten is what an uploaded run carries. It is a
# variable so a checkout can say `make bench BENCH_COUNT=1` when it only wants
# to know that the benchmarks still run.
BENCH_COUNT?=10
# -run='^$$' so no test runs beside the benchmarks: a run that also executed the
# suites would charge their wall clock to the benchmark task and, in CI, would
# need the Postgres the suites need.
bench:
go test -timeout $(TEST_TIMEOUT) -run='^$$' -bench=. -benchmem -count=$(BENCH_COUNT) $(PKG)
# CSS pipeline: sassc -> minify -> content-hashed filename. The running service
# globs web/static/main.min.*.css at startup, so the hash in the name is the
# cache-busting version. Requires the shared scss partials installed at
# $(ASSETS)/scss (core.sr.ht `make install`) and scss/main.scss to exist.
# Produces exactly ONE web/static/main.min.<sha256[:8]>.css; old ones and the
# intermediate main.css are removed.
css:
mkdir -p web/static
rm -f web/static/main.css web/static/main.min.*.css
$(SASSC) $(SASSC_INCLUDE) scss/main.scss web/static/main.css
minify -o web/static/main.min.css web/static/main.css
mv web/static/main.min.css \
web/static/main.min.$$(sha256sum web/static/main.min.css | cut -c1-8).css
rm -f web/static/main.css
# Local development run. Requires a ./config.ini in the working directory (or
# ../config.ini, /etc/sr.ht/config.ini) carrying the instance's shared
# [sr.ht]/[webhooks] keys plus a [spec.sr.ht] section.
run-dev: build
./$(BIN) -b localhost:5091
# schema.sql is installed as $(SCHEMAFILE) because `specsrht-migrate init`
# applies it wholesale on a fresh database; without it, init works from a
# checkout and fails on a packaged install.
#
# Static assets are not installed at all any more. web/templates.go go:embed-s
# the whole static directory, so those files are already inside the binary and a
# second copy under $(ASSETS)/$(SERVICE)/static is dead weight in the apk that
# nothing reads — the config has no static-dir key to point at it. It is also
# why check-css guards this target: an unstyled binary cannot be repaired by
# copying a stylesheet next to it afterwards.
install: check-css build
@$(MAKE) install-files
# The copying half of `install`, with nothing to build in front of it — the
# target a packaging run calls once it has already built the binaries, so that
# the files it stages are the very bytes it built and not a second compilation
# of the same sources.
#
# That distinction is the whole point and it is not theoretical. $(BIN) above is
# .PHONY, so `install` recompiles; abuild runs package() in a FRESH abuild
# process under fakeroot, which re-sources the APKBUILD and never calls
# build(). Nothing build() exported reaches package(), the CI cache pins among
# it, so `make install` there relinked both binaries from a cold cache — a
# second binary, shipped, that nothing in the pipeline had tested.
#
# It is invoked through a sub-make rather than listed as a third prerequisite of
# `install` on purpose: /usr/share/abuild/default.conf exports
# MAKEFLAGS=-j$(nproc), prerequisites of one target run in parallel under -j,
# and `install: check-css build install-files` would let the copying start
# beside the build it is supposed to follow. A recipe line always runs after the
# prerequisites are done.
#
# There is no check in front of the copies: `install -Dm755 specsrht` on a
# missing file is already a fatal error naming it, which is the right report for
# the one way this target can be called too early.
install-files:
$(INSTALL) -Dm755 $(BIN) $(DESTDIR)$(BINDIR)/$(BIN)
$(INSTALL) -Dm755 $(MIGRATE_BIN) $(DESTDIR)$(BINDIR)/$(MIGRATE_BIN)
mkdir -p $(DESTDIR)$(MIGRATIONDIR)
$(INSTALL) -Dm644 -t $(DESTDIR)$(MIGRATIONDIR) migrations/*.sql
$(INSTALL) -Dm644 schema.sql $(DESTDIR)$(SCHEMAFILE)
# check-css is the packaging gate: no stylesheet, no release. It exists because
# the failure it catches is invisible at build time — `go build` succeeds
# perfectly well with an unstyled static/, since //go:embed takes the directory
# and not the file, and the first sign of trouble is an unstyled page in
# production.
#
# It counts the matches rather than merely asking whether there are any, because
# TWO stylesheets are as wrong as none and quieter: web/ resolves this glob and
# takes the first match, so a second file makes the served stylesheet depend on
# readdir order. `css` removes the previous build for that reason, which is
# exactly why this gate must not assume it did.
#
# `set --` puts the matches in the positional parameters, so the count is $$#
# and no `wc` output has to be parsed. Splitting on whitespace is fine: every
# name it can see was produced by the recipe above, out of a hex digest.
check-css:
@set -- $$(ls $(CSS) 2>/dev/null); \
if [ $$# -eq 0 ]; then \
echo "error: no $(CSS) — run 'make css' before 'go build' (the CSS is embedded)"; \
exit 1; \
elif [ $$# -gt 1 ]; then \
echo "error: $$# files match $(CSS) — web/ takes the first, so the choice is arbitrary."; \
echo " run 'make clean && make css' to get back to one:"; \
for f in "$$@"; do echo " $$f"; done; \
exit 1; \
fi
clean:
rm -f $(BIN) $(MIGRATE_BIN)
rm -f web/static/main.css web/static/main.min.*.css
.PHONY: all build test cover bench css run-dev install install-files check-css \
clean $(BIN) $(MIGRATE_BIN)