M .build.yml => .build.yml +107 -45
@@ 1,3 1,11 @@
+# builds.sr.ht manifest for compare.sr.ht. One linear pipeline: install the
+# cache helper, assemble the shared SCSS, restore caches, package with abuild,
+# publish the apk, save the caches.
+#
+# The reasoning behind every task lives in docs/ci.md, not here: builds.sr.ht
+# stores the submitted manifest in a varchar(16384), so a manifest over 16 KiB
+# cannot be submitted at all — and the failure is a branch with no CI, not a red
+# build. Add paragraphs to docs/ci.md and a pointer here.
image: alpine/edge
packages:
- abuild
@@ 5,6 13,9 @@ packages:
- go
- git
- rclone
+ # For `make css`, not for the package — see docs/ci.md#packages.
+ - sassc
+ - minify
secrets:
# File secret `apk-ci-s3`, installed at ~/.apk-ci.env, containing
# APK_CI_S3_ACCESS_KEY / APK_CI_S3_SECRET_KEY for the Garage `repo` bucket.
@@ 20,37 31,23 @@ environment:
APK_REPO: alpine/v3.22/bigbes/x86_64
S3_BUCKET: repo
S3_ENDPOINT: https://s3.bigb.es
+ # CORE_VER must track the deployment's SRHT_CORE_VER; BOOTSTRAP_REV is the
+ # submodule commit core.sr.ht pins at that tag. See docs/ci.md#environment.
+ CORE_VER: "0.84.5"
+ BOOTSTRAP_REV: 779ad9f174ea5ab7e755f6df0ec9e5912d67dd16
submitter:
git.sr.ht:
allow-refs:
- refs/heads/master
+ - "refs/tags/v*"
tasks:
- - keygen: |
- # abuild insists on signing what it builds, but this key is deliberately
- # throwaway: generated per build, dies with the VM, trusted by nothing.
- # Clients verify against the index instead, which is rebuilt and signed on
- # phoebe by the garage stack's apk-mirror service — it indexes this repo
- # with --allow-untrusted precisely because of this.
- #
- # -i installs the public half into /etc/apk/keys. Without it abuild's own
- # final "update the local repository index" step dies with UNTRUSTED
- # signature, after having built the package perfectly well.
- SUDO=sudo abuild-keygen -a -n -i -q
- - version: |
- cd "$REPO"
- ver="0.0.$(git rev-list --count HEAD)"
- sed -i "s/^pkgver=.*/pkgver=$ver/" APKBUILD
- echo "export PKGVER=$ver" >> ~/.buildenv
- echo "building $ver"
- - cacher: |
- # S3-backed CI cache helper (go.bigb.es/cacher), dogfooded from its own
- # published release — the same bootstrap the bencher/ci-cacher builds use.
- mkdir -p ~/.local/bin
- curl -sSL "https://bigbes.pages.srht.bigb.es/ci-cacher/cacher-linux-amd64" \
- -o ~/.local/bin/cacher
- chmod +x ~/.local/bin/cacher
- echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.buildenv
- export PATH="$HOME/.local/bin:$PATH"
+ # S3-backed CI cache helper (go.bigb.es/cacher), installed from its own
+ # published release. Two tasks and not one: install.sh appends its PATH export
+ # to ~/.buildenv, which only the NEXT task sources. The installer verifies
+ # what it downloads against checksums.txt. See docs/ci.md#cacher.
+ - cacher_install: |
+ curl -fsSL https://bigbes.pages.srht.bigb.es/ci-cacher/install.sh | sh
+ - cacher_init: |
cacher init \
--endpoint https://s3.bigb.es \
--region garage \
@@ 58,30 55,90 @@ tasks:
--prefix sr-ht-compare/deps \
--key-file ~/.s3-cache-key-id \
--secret-file ~/.s3-cache-key-secret
+ - scss: |
+ # Assemble the shared sourcehut partials no apk ships, the way
+ # core.sr.ht's `make install-scss` would, cached by the two pins so an
+ # outage at git.sr.ht or github.com can't fail us. --exec runs on a miss
+ # and seeds the cache after; it sees exported vars only, hence the inline
+ # key and the single quotes. See docs/ci.md#scss.
+ cacher dir download "scss/${CORE_VER}-${BOOTSTRAP_REV}.tar.zst" ~/scss --exec '
+ git clone --depth 1 --branch "$CORE_VER" \
+ https://git.sr.ht/~sircmpwn/core.sr.ht /tmp/core
+ mkdir -p ~/scss/bootstrap
+ cp /tmp/core/scss/*.scss /tmp/core/scss/*.css ~/scss/
+ git init -q /tmp/bootstrap
+ git -C /tmp/bootstrap remote add origin https://github.com/twbs/bootstrap
+ git -C /tmp/bootstrap fetch -q --depth 1 origin "$BOOTSTRAP_REV"
+ git -C /tmp/bootstrap checkout -q FETCH_HEAD
+ cp -r /tmp/bootstrap/scss ~/scss/bootstrap/scss
+ '
+ sudo mkdir -p /usr/share/sourcehut
+ sudo cp -r ~/scss /usr/share/sourcehut/scss
+ - keygen: |
+ # Throwaway signing key, and -i is not optional: docs/ci.md#keygen.
+ SUDO=sudo abuild-keygen -a -n -i -q
+ - version: |
+ # ONE `git describe` decides the pkgver: a tag, else <tag>_git<n>, else the
+ # family's 0.0.<commit count>. EXPORTED rather than sed-ed into the tracked
+ # APKBUILD (which reads $PKGVER), because rewriting a tracked file flips
+ # the VCS stamp Go records into every binary built afterwards to dirty —
+ # do not "tidy" it back into a sed. The tree is printed because this is the
+ # last moment it is provably clean. See docs/ci.md#version.
+ cd "$REPO"
+ desc=$(git describe --tags --always --dirty)
+ base=${desc%-dirty}
+ case "$base" in
+ v*-g*) n=${base%-g*}; ver="${n%-*}"; ver="${ver#v}_git${n##*-}" ;;
+ v*) ver="${base#v}" ;;
+ *) ver="0.0.$(git rev-list --count HEAD)" ;;
+ esac
+ echo "export PKGVER=$ver" >> ~/.buildenv
+ echo "building $ver from $desc"
+ git status --porcelain
- cache_restore: |
- # Restore the Go module and build caches, both keyed by go.sum: the
- # dependency tree dominates compile time, and it only changes when go.sum
- # does. A miss is just a cold build, never an error.
+ # Go module and build caches keyed by go.sum; --optional makes a genuine
+ # miss a cold build rather than an error, which `|| true` could not tell
+ # apart from bad credentials. abuild re-pins both, so the exports here are
+ # only for the repair block below. See docs/ci.md#cache_restore.
KEY_MOD=$(cacher key "gomod/{hash}.tar.zst" --hash-from "$REPO/go.sum")
KEY_GOC=$(cacher key "gocache/{hash}.tar.zst" --hash-from "$REPO/go.sum")
echo "export KEY_MOD=$KEY_MOD KEY_GOC=$KEY_GOC" >> ~/.buildenv
- # abuild redirects the Go caches into its throwaway $tmpdir (and an
- # upstream typo slaves GOMODCACHE to GOCACHE), so env exports here can't
- # stick — the APKBUILD's build() re-pins both to these home locations.
- cacher dir download "$KEY_MOD" ~/go/pkg/mod || true
- cacher dir download "$KEY_GOC" ~/.cache/go-build || true
+ cacher dir download "$KEY_MOD" ~/go/pkg/mod --optional
+ cacher dir download "$KEY_GOC" ~/.cache/go-build --optional
+ # Repair block for the HALF-restored module cache — the normal failure
+ # here, not a freak one, and it reads like a code bug. Do not remove and
+ # do not soften to `|| true`:
+ # docs/ci.md#the-half-restored-module-cache.
+ cd "$REPO"
+ chmod -R u+w ~/go/pkg/mod 2>/dev/null || true
+ if ! go mod verify >/dev/null 2>&1; then
+ echo "restored module cache did not verify — discarding it"
+ rm -rf ~/go/pkg/mod
+ fi
+ go mod download all
+ go mod verify
+ # -mod=readonly is the default, so neither line above can rewrite go.mod
+ # or go.sum — but an untracked or modified file here is a "+dirty" stamp
+ # in the packaged binary, so say so out loud rather than trusting the
+ # flag. docs/ci.md#cache_restore.
+ git status --porcelain
- build: |
cd "$REPO"
- # -d: makedepends are already installed via `packages:` above, so skip
- # abuild's own dependency resolution (which would want to sudo apk add).
+ # -d: makedepends come from `packages:` above, so skip abuild's own
+ # dependency resolution. The APKBUILD runs `make css` before the compile
+ # and `make check-css` after it, because web/ go:embed-s static/.
+ # See docs/ci.md#build.
REPODEST=$HOME/packages abuild -d
find "$HOME/packages" -name '*.apk'
- - cache_save: |
- # Seed the caches only when this go.sum has no entry yet — on a hit the
- # tarballs are already up there and re-uploading identical bytes is waste.
- cacher exists "$KEY_MOD" || cacher dir upload "$KEY_MOD" ~/go/pkg/mod
- cacher exists "$KEY_GOC" || cacher dir upload "$KEY_GOC" ~/.cache/go-build
- publish: |
+ # The gate is the honest answer to a build handed no secrets, not a
+ # fallback: without ~/.apk-ci.env every earlier task has still run and a
+ # signed apk is sitting in $HOME/packages. See docs/ci.md#publish.
+ if [ ! -r ~/.apk-ci.env ]; then
+ echo "no ~/.apk-ci.env: this build has no apk repo credentials"
+ echo "the package was built and signed, and is not published"
+ exit 0
+ fi
set +x # never echo the S3 credentials into the build log
. ~/.apk-ci.env
export RCLONE_CONFIG_GARAGE_TYPE=s3
@@ 92,12 149,17 @@ tasks:
export RCLONE_CONFIG_GARAGE_ACCESS_KEY_ID="$APK_CI_S3_ACCESS_KEY"
export RCLONE_CONFIG_GARAGE_SECRET_ACCESS_KEY="$APK_CI_S3_SECRET_KEY"
set -x
- # Upload only; never delete. Old versions stay so a pinned deployment can
- # always be rebuilt — the same reason the upstream mirror is append-only.
- # abuild nests output under $REPODEST/<repo>/<arch>/, so flatten by
- # uploading each file to a fixed prefix rather than mirroring the tree.
+ # Upload only, never delete; abuild nests output under
+ # $REPODEST/<repo>/<arch>/, so flatten to a fixed prefix rather than
+ # mirroring the tree. See docs/ci.md#publish.
find "$HOME/packages" -name '*.apk' -print | while read -r f; do
rclone copyto "$f" "garage:$S3_BUCKET/$APK_REPO/$(basename "$f")"
echo "uploaded $(basename "$f")"
done
echo "published; apk-mirror on phoebe re-indexes within 15 minutes"
+ - cache_save: |
+ # AFTER publish so an S3 hiccup cannot strand a good apk, and fatal on
+ # purpose. Without --force an upload skips a key already there, so no
+ # `cacher exists ||` guard is needed. See docs/ci.md#cache_save.
+ cacher dir upload "$KEY_MOD" ~/go/pkg/mod
+ cacher dir upload "$KEY_GOC" ~/.cache/go-build
M .gitignore => .gitignore +27 -0
@@ 4,6 4,33 @@ node_modules/
# Compiled service binary
/comparesrht
+# The three scratch directories abuild works in: with `source=""` and
+# `builddir="$startdir"` (see APKBUILD) it unpacks into ./src, stages the
+# package into ./pkg and keeps ./tmp for the toolchain caches it redirects —
+# i.e. all three are inside the checkout it is packaging.
+#
+# Listing them is not tidiness. Go records vcs.modified in every binary built
+# inside a repository and reads that flag from `git status --porcelain`, which
+# counts UNTRACKED files — so one untracked directory left beside the build is
+# what makes a released binary stamp itself "+dirty". /tmp/ is the one that is
+# easy to miss: abuild defaults `tmpdir` to "$startdir/tmp" and exports GOTMPDIR
+# to it, so every `go build` puts its work directory INSIDE this checkout.
+/src/
+/pkg/
+/tmp/
+
+# `make css` output: the intermediate and the hashed result. It is derived from
+# scss/main.scss and a `base` partial that belongs to the deployment's core.sr.ht
+# version, so it is build output — the `scss` task of .build.yml materializes the
+# partial and the APKBUILD runs `make css` before the compile.
+#
+# NOTE: web/static/main.min.79713f25.css is still TRACKED in this revision, and a
+# tracked file is unaffected by these patterns. Dropping it from the index is the
+# second half of this change, and waits on the first CI run confirming that
+# `make css` reproduces it — see docs/ci.md#css.
+/web/static/main.css
+/web/static/main.min.*.css
+
# Transient build artifacts
*.tmp
M APKBUILD => APKBUILD +54 -7
@@ 4,11 4,25 @@
# repo.bigb.es/alpine/v3.22/bigbes. The srht deployment installs it from there
# instead of cloning and compiling this repo inside its Dockerfile.
#
-# pkgver is rewritten by CI to 0.0.<commit count> before abuild runs — a
-# monotonic, unique-per-commit version that the deployment can pin. The literal
-# below is only what a local `abuild` would use.
+# pkgver is READ FROM THE ENVIRONMENT, not sed-ed into this file. The `version`
+# task of .build.yml runs one `git describe` and exports PKGVER as whichever of
+# three shapes that produces: the tag (`0.2.0`), the tag plus commits since it
+# (`0.2.0_git7`, because Alpine's grammar rejects `v0.2.0-7-gabc1234` and `_git`
+# sorts after the release), or `0.0.<commit count>` when there are no tags at
+# all — which is this repository today. Do not restate one of the three here as
+# if it were the rule; see docs/ci.md#version.
+#
+# Nothing rewrites a tracked file, and that is not a style choice: Go records
+# vcs.modified in every binary it builds inside a repository, it reads that flag
+# from `git status --porcelain`, and a CI task that patched this line would
+# therefore stamp "+dirty" into every packaged binary for the life of the apk.
+# Measured on go1.26.5; .gitignore covers the rest of what abuild writes into
+# the checkout.
+#
+# A local `abuild` has no PKGVER and builds 0.0.0, which is what a package built
+# by hand honestly is.
pkgname=compare.sr.ht
-pkgver=0.0.0
+pkgver="${PKGVER:-0.0.0}"
pkgrel=0
pkgdesc="Stateless diff/compare viewer for a sourcehut instance"
url="https://sourcecraft.dev/bigbes/sr-ht-compare"
@@ 32,17 46,50 @@ build() {
# exports — so CI's cache_restore/cache_save tasks see them survive.
export GOCACHE="$HOME/.cache/go-build"
export GOMODCACHE="$HOME/go/pkg/mod"
- # web/static (bundle.js + hashed CSS) is committed and go:embed-ed, so
- # there is no `make css` / `make bundle` step here — see README.
+ # CSS strictly before the binary: web/ go:embed-s static/, so a stylesheet
+ # built afterwards would never make it into the binary. The shared scss
+ # partials are assembled at ASSETS/scss by the `scss` task of .build.yml —
+ # no apk ships them.
+ #
+ # The frontend bundle is NOT built here: web/static/bundle.<hash>.js is
+ # committed, and building it would want npm and a network. `make bundle` is
+ # an upgrade step, run by hand.
+ make css ASSETS=/usr/share/sourcehut
+
+ # web/static/main.min.<hash>.css is still COMMITTED in this revision, and
+ # `make css` has just replaced it. These two lines are how the first CI run
+ # reports whether the pipeline reproduces the committed bytes: a clean
+ # `git status` here means the committed file can be dropped from the tree,
+ # and anything else names the difference. See docs/ci.md#css.
+ #
+ # `|| true` because a diagnostic must never be the reason a build fails.
+ git status --porcelain -- web/static || true
+ sha256sum web/static/main.min.*.css || true
+
# -modcacherw matters beyond convenience: without it the module cache is
# extracted read-only, and the CI cache tarball made from it can't be
# unpacked on the next build (mkdir into 0555 dirs fails).
CGO_ENABLED=0 make build GOFLAGS="-trimpath -modcacherw"
+
+ # `go build` succeeds perfectly well with an unstyled static/ — //go:embed
+ # takes the directory, not the file — so a `make css` that produced nothing
+ # would ship an unstyled service and fail nothing. This is the gate, and it
+ # runs before anything is staged.
+ make check-css
}
package() {
cd "$builddir"
# The Makefile honours DESTDIR; ASSETS must stay the real runtime path so
# the binary's static-dir glob resolves after install.
- make install DESTDIR="$pkgdir" PREFIX=/usr ASSETS=/usr/share/sourcehut
+ #
+ # `install-files` and not `install`, because this function must not compile.
+ # abuild runs package() in a FRESH abuild process under fakeroot, which
+ # re-sources this file and never calls build() — so the GOCACHE/GOMODCACHE
+ # pins above are gone here, $(BIN) is .PHONY and `install` depends on
+ # `build`, and `make install` would therefore relink the binary from a cold
+ # cache. The apk would then ship a second compilation that nothing in this
+ # file has looked at. `install -Dm755` on a missing comparesrht is a fatal
+ # error naming the file, so a package() reached without a build() says so.
+ make install-files DESTDIR="$pkgdir" PREFIX=/usr ASSETS=/usr/share/sourcehut
}
M Makefile => Makefile +87 -4
@@ 16,6 16,12 @@ STATICDIR?=$(ASSETS)/$(SERVICE)/static
SASSC?=sassc
SASSC_INCLUDE=-I$(ASSETS)/scss
+MINIFY?=minify
+
+# The glob web/templates.go resolves at startup (cssGlob). Named once so that
+# `css`, which removes the previous build's file, and `check-css`, which counts
+# what is left, cannot drift apart.
+CSS=web/static/main.min.*.css
all: build
@@ 33,15 39,67 @@ test:
# base.scss and bootstrap/scss there) and scss/main.scss to exist (phase 2b).
# Produces exactly ONE web/static/main.min.<sha256[:8]>.css (old ones and the
# intermediate main.css are removed).
+#
+# The three preflight checks are here because each of their failures is
+# otherwise illegible. A missing minifier stops the pipeline after sassc has
+# already written main.css, and the shell's "command not found" names a binary
+# rather than a package; a missing $(ASSETS)/scss/base.scss is the family's
+# standing rake — NO PACKAGE MANAGER SHIPS THAT PARTIAL, it is materialized by
+# core.sr.ht's own `make install` locally and by the `scss` task of .build.yml
+# in CI, and sassc's report of it is one line about an import.
css:
+ @command -v $(SASSC) >/dev/null 2>&1 || { \
+ echo "error: $(SASSC) not found — install sassc (apk add sassc, brew install sassc)"; \
+ exit 1; }
+ @command -v $(MINIFY) >/dev/null 2>&1 || { \
+ echo "error: $(MINIFY) not found — install tdewolff/minify (apk add minify," \
+ "go install github.com/tdewolff/minify/v2/cmd/minify@latest)"; \
+ exit 1; }
+ @[ -f $(ASSETS)/scss/base.scss ] || { \
+ echo "error: no $(ASSETS)/scss/base.scss — the shared partial is not packaged;" \
+ "materialize it with core.sr.ht's 'make install' (or the scss task of .build.yml)"; \
+ exit 1; }
mkdir -p web/static
- rm -f web/static/main.css web/static/main.min.*.css
+ rm -f web/static/main.css $(CSS)
$(SASSC) $(SASSC_INCLUDE) scss/main.scss web/static/main.css
- minify -o web/static/main.min.css 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
+# 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 web/static (//go:embed takes the directory,
+# not the file), and the first sign of trouble is an unstyled page in
+# production.
+#
+# It answers about the DISK, and that is the limit of it: nothing here says the
+# file it counted is the file a binary embedded.
+#
+# It COUNTS the matches rather than merely asking whether there are any, because
+# two stylesheets are as wrong as none and quieter: web/templates.go resolves
+# cssGlob 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 — a gate that
+# promises "exactly one" and checks "at least one" is a promise the next person
+# builds on.
+#
+# `set --` puts the matches in the positional parameters, so the count is $$#
+# and no `wc` output has to be parsed. It is fine that this splits on
+# whitespace: 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 css' to get back to one:"; \
+ for f in "$$@"; do echo " $$f"; done; \
+ exit 1; \
+ fi
+
# Frontend diff/tree bundle: built once, output committed to web/static. The
# filename carries a content hash (like the CSS) so a deploy busts the browser
# cache — a stale bundle.js is otherwise served for up to max-age and can leave
@@ 64,9 122,34 @@ bundle:
run-dev: build
./$(BIN) -b localhost:5090
-install: build
+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 and checked the binary,
+# so that the files it stages are the very bytes it checked and not a second
+# compilation of the same sources.
+#
+# That distinction is the whole point and it is not theoretical. `build` above is
+# .PHONY (Go decides staleness itself, a real file target would never rebuild
+# after a source edit), so `install` recompiles; abuild runs package() in a FRESH
+# abuild process under fakeroot, which re-sources the APKBUILD and never calls
+# build(), so the GOCACHE/GOMODCACHE pins are gone and that recompilation comes
+# from a cold cache. The packaged binary would be one nothing had inspected.
+#
+# 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 comparesrht` on a
+# missing file is already a fatal error naming the file, which is exactly the
+# right report for the one way this target can be called too early.
+install-files:
install -Dm755 $(BIN) $(DESTDIR)$(BINDIR)/$(BIN)
mkdir -p $(DESTDIR)$(STATICDIR)
install -Dm644 -t $(DESTDIR)$(STATICDIR) web/static/*
-.PHONY: all build test css bundle run-dev install
+.PHONY: all build test css check-css bundle run-dev install install-files
A docs/ci.md => docs/ci.md +346 -0
@@ 0,0 1,346 @@
+# CI: what `.build.yml` does and why
+
+The manifest is deliberately short on prose. builds.sr.ht stores a submitted
+manifest in a `varchar(16384)`, so a manifest that grows past 16 KiB cannot be
+submitted at all — the failure is at submission time and reads like nothing in
+particular: the branch simply has no CI. Rationale therefore lives here, and the
+manifest carries pointers.
+
+The pipeline is one linear job on `alpine/edge`: install the cache helper,
+assemble the shared SCSS, make a throwaway signing key, decide the version,
+restore the Go caches, package with `abuild`, publish the apk to
+`repo.bigb.es/alpine/v3.22/bigbes`, save the caches.
+
+It is triggered by a push to the **sourcehut** side. A push to sourcecraft
+cannot reach builds.sr.ht; the gitsync mirror is what puts the commit on
+`git.srht.bigb.es`, and that push is what submits the job. So the mirror is on
+the critical path for the package repository, not merely an offsite copy.
+
+## packages
+
+`sassc` and `minify` are for `make css`, not for the package: the stylesheet is
+compiled here and embedded into the binary, and the apk has no runtime
+dependency on either.
+
+`curl` is for the `cacher_install` bootstrap and `rclone` for `publish`. There is
+no `nodejs`/`npm`: `web/static/bundle.<hash>.js` is committed, and `make bundle`
+is an upgrade step run by hand rather than a build step.
+
+There is no `postgresql`. This service is stateless — it holds no database at
+all — and its suites are hermetic: `gitx/fixture_test.go` builds a bare
+repository with the local `git`, and `web/web_test.go` is `httptest` plus
+`ecoretest`. Nothing in the tree talks to a live git.sr.ht API.
+
+## secrets
+
+Three, all account-level and shared with the sibling services:
+
+| secret | lands at | used by |
+|---|---|---|
+| `apk-ci-s3` | `~/.apk-ci.env` | `publish` |
+| `7dde4219-…` | `~/.s3-cache-key-id` | `cacher_init` |
+| `0e5b3530-…` | `~/.s3-cache-key-secret` | `cacher_init` |
+
+They are file secrets. Listing them is what turns `publish` and the cache tasks
+on; a manual submission that asks for no secrets still runs the interesting part
+of the pipeline and stops at `cacher_init`, which is the right place to notice.
+
+`apk-ci-s3` is referenced by name and the other two by UUID, which is only
+because that is how they were written in the donor manifests; both forms work.
+
+## environment
+
+`CORE_VER` **must track the deployment's `SRHT_CORE_VER`**, and `BOOTSTRAP_REV`
+is the Bootstrap submodule commit `core.sr.ht` pins at that tag. The two being
+out of step means this service renders against different partials than the rest
+of the instance, which shows up as a page that is subtly the wrong shape and as
+nothing at all in any log. They are `0.84.5` / `779ad9f1` here because that is
+what `srht/versions.env` pins in the deployment repo; bump all of them together.
+
+`REPO` is the clone directory `sources:` produces, and every task that touches
+the checkout `cd`s into it — task bodies do not inherit a working directory from
+each other.
+
+## cacher
+
+`cacher` (`go.bigb.es/cacher`) is an S3-backed cache helper, installed from its
+own release on pages.sr.ht.
+
+It is **two tasks and not one** because `install.sh` appends its `PATH` export
+to `~/.buildenv`, and `~/.buildenv` is sourced by the *next* task's preamble — a
+single task would install the binary and then not find it on `PATH`.
+
+The installer is fetched with `curl -fsSL` and verifies what it downloads
+against `checksums.txt`. The predecessor of this task fetched the raw binary
+with a bare `curl -sSL`; without `-f`, an HTTP error is not an error to `curl`,
+so a 404 page was written to `~/.local/bin/cacher` and `chmod +x`-ed, and the
+first failure was a syntax error out of a shell reading HTML.
+
+## scss
+
+`sassc` needs the shared sourcehut partials, and **no apk ships them**: they are
+core.sr.ht's own `scss/` plus the Bootstrap 4 tree it pins as a submodule. The
+task clones both and drops them in `/usr/share/sourcehut/scss`, which is the
+`ASSETS` the Makefile and the APKBUILD name.
+
+The whole thing is cached under a key made of the two pins
+(`scss/$CORE_VER-$BOOTSTRAP_REV.tar.zst`), so an outage at git.sr.ht or GitHub
+cannot fail a build that changed nothing. `--exec` runs only on a cache miss and
+seeds the cache afterwards; the block sees exported variables only, which is why
+the cache key is spelled inline in the `cacher` invocation rather than computed
+inside the block, and why the block is in single quotes.
+
+It runs before `keygen` and before anything Go, because the stylesheet has to
+exist before the compiler does — see [css](#css).
+
+## keygen
+
+`abuild-keygen -a -n -i -q` makes a throwaway package signing key.
+
+`-i` is not optional: without it the key is generated but not installed into
+`/etc/apk/keys`, and `abuild`'s own final "update the local repository index"
+step then dies with an UNTRUSTED signature after having built the package
+perfectly well.
+
+The key is per-build and dies with the VM. That is fine because nothing verifies
+these signatures — the apk index on `repo.bigb.es` is rebuilt and signed on
+phoebe by the garage stack's `apk-mirror` service, which indexes this repo with
+`--allow-untrusted` precisely because of this.
+
+## version
+
+One `git describe` decides the apk's `pkgver`. Alpine's version grammar does not
+accept `v1.2.3-4-gabc1234`, so a describe with commits since the tag is
+rewritten to `1.2.3_git4`; `_git` sorts **after** the release in Alpine's
+comparison, which is what makes an untagged master build look newer than the tag
+it follows. With no tags at all — which is this repository today — it falls back
+to `0.0.<commit count>`, monotonic and unique per commit, which a deployment can
+pin.
+
+Tags are in `allow-refs` for exactly this reason: pushing `v0.9.0` is what
+produces the `0.9.0` apk.
+
+`$ver` is **exported** through `~/.buildenv` as `PKGVER`, and the APKBUILD reads
+`pkgver="${PKGVER:-0.0.0}"`. It is not `sed`-ed into the tracked APKBUILD, and
+this is the one thing in this file that must not be "tidied" back:
+
+> Go records `vcs.modified` in every binary it builds inside a repository, and
+> it reads that flag from `git status --porcelain`. Measured on go1.26.5, a
+> `sed -i` of a tracked file sets `vcs.modified=true` and Go stamps `+dirty`
+> into `Main.Version` for the whole `abuild` run — for every binary in the
+> package, permanently, for the life of the apk.
+
+The export alone is not enough. Any **untracked** file present at `go build` time
+does the same thing, and `abuild` works inside the checkout with
+`builddir="$startdir"` and exports `GOTMPDIR=$startdir/tmp` — so `./src`,
+`./pkg` and `./tmp` all appear inside the tree it is packaging. `.gitignore`
+covers those three, and Go does not count ignored files.
+
+The task ends with a bare `git status --porcelain` because this is the last
+moment the tree is provably clean, and the output of a clean one is nothing.
+
+## cache_restore
+
+The Go module cache and build cache are keyed by the hash of `go.sum`, so a
+build that changed no dependency reuses both.
+
+`--optional` and not `|| true`. They are not the same promise: `--optional`
+returns 0 only for a genuine cache miss, while `|| true` swallows every other
+outcome too — bad credentials, a dead bucket, a typo in the prefix — and turns a
+misconfigured cache into a pipeline that is merely slow, silently, forever.
+
+The exports here are only for the repair block below and for `cache_save`:
+`abuild` redirects both caches into its throwaway `$tmpdir` (and an upstream typo
+in `abuild.in` slaves `GOMODCACHE` to `GOCACHE`), so environment exports from a
+task cannot stick. The APKBUILD's `build()` re-pins both to the same home
+locations, after abuild's own exports, which is what makes the tarballs saved at
+the end be the ones the compile used.
+
+### The half-restored module cache
+
+The repair block is not defensive padding. A partially restored module cache is
+the *normal* failure mode of this arrangement — an interrupted upload, a
+truncated object, a key written while a build was still running — and what it
+produces is a compile error deep in a dependency, which reads exactly like a bug
+in the code under test.
+
+So: make the tree writable (the module cache is mode 555 and `rm -rf` cannot
+remove it otherwise), ask `go mod verify` whether what came back is intact, and
+throw the whole thing away if it is not. Then `go mod download all` and verify
+for real, and let *that* failure be fatal.
+
+Do not soften the final `go mod verify` to `|| true`. A build that proceeds with
+a module cache it could not verify is a build whose result means nothing.
+
+The second `git status --porcelain` is there because `-mod=readonly` is the
+default and neither command *can* rewrite `go.mod` or `go.sum` — but a dirty
+tree at this point is a `+dirty` apk, so it is said out loud rather than trusted
+to a flag.
+
+## test
+
+`gofmt -l .` piped to a file and then `test ! -s`, rather than a bare `gofmt -l`:
+`gofmt -l` exits 0 whether or not it printed anything, so the only way to fail on
+its output is to look at the output. The `tee` keeps the offending filenames in
+the build log, where they are the entire diagnosis.
+
+`go vet ./...` next, then `make test` — the Makefile, not a bare `go test ./...`,
+so that the test command lives in one place.
+
+No Postgres and no network. Every suite in this tree is hermetic:
+`gitx/fixture_test.go` builds a bare repository with the local `git` binary, and
+`web/web_test.go` is `httptest` plus `ecoretest`. The APKBUILD's `!check` is
+about not running these a second time inside `abuild`, not about them needing
+something the VM lacks.
+
+## build
+
+`REPODEST=$HOME/packages abuild -d` builds and stages the apk.
+
+`-d` disables abuild's dependency check: the makedepends are already installed
+by the manifest's `packages:` list, and abuild has no way to know that.
+
+`builddir="$startdir"` in the APKBUILD means abuild packages *this checkout in
+place* rather than unpacking a tarball. There is no tarball to unpack — the
+package is built from the commit under test, which has not been released
+anywhere yet.
+
+`build()` runs `make css` before the compile and `make check-css` after it, and
+`package()` runs `make install-files` rather than `make install`. Both are
+explained under [css](#css) and [install-files](#install-files).
+
+## publish
+
+The `[ ! -r ~/.apk-ci.env ]` gate is the honest answer to a build that was handed
+no secrets, not a fallback: on a manual submission without the secret, every
+earlier task has still run and a signed apk is sitting in `$HOME/packages`, and
+saying so and exiting 0 is more useful than a red build about a credential
+nobody asked for. On a push the secret is there and this publishes.
+
+`set +x` before sourcing `~/.apk-ci.env` and `set -x` after. Every task runs
+under `set -x`, so without this the S3 access key and secret are echoed into a
+build log that is world-readable.
+
+The upload is `rclone copyto` per file — copy only, never delete, never sync. A
+`sync` would mirror local absence onto the bucket, and the bucket holds every
+previously published version of every service on the instance, which is what
+lets a pinned deployment be rebuilt. abuild nests its output under
+`$REPODEST/<repo>/<arch>/`, so each file is copied to a fixed prefix rather than
+the tree being mirrored.
+
+`apk-mirror` on phoebe re-indexes within 15 minutes; nothing here waits for it.
+
+## cache_save
+
+**After `publish`**, deliberately: a cache upload that fails must not strand an
+apk that was built and tested successfully but never shipped.
+
+Fatal on purpose, though — an upload failure here means the next build pays for
+a cold cache, and that is worth knowing about rather than hiding behind
+`|| true`.
+
+There is no `cacher exists "$KEY" ||` guard in front of either upload. It was
+dead code: without `--force`, `cacher dir upload` already skips a key that is
+present, so the guard asked the same question twice and its only real effect was
+to make a failure of the `exists` call look like a decision not to upload.
+
+## css
+
+The stylesheet is compiled from `scss/main.scss` against the shared partials and
+is embedded into the binary by `//go:embed static` (`web/templates.go`), so it
+has to exist **before** the compiler runs. Hence `make css` at the top of
+`build()` and a fatal `make check-css` after the compile.
+
+`check-css` counts the matches of `web/static/main.min.*.css` and fails on zero
+*and* on two. Two is as wrong as none and quieter: `web/templates.go` resolves
+`cssGlob` and takes the first match, so a second file makes the served
+stylesheet depend on readdir order.
+
+### The committed stylesheet, and the two steps
+
+`web/static/main.min.79713f25.css` is **still tracked** in this revision. The end
+state is the one the siblings have — the file is build output, ignored, and
+produced by CI — but getting there is two steps and this is the first.
+
+Step one, this change: CI builds the partials, `make css` runs in `build()`,
+`check-css` gates the result, and the committed file stays in the index. The
+`.gitignore` patterns are already in place and do nothing to it, because a
+tracked file is unaffected by `.gitignore`.
+
+`build()` therefore prints two lines right after `make css`:
+
+```
+git status --porcelain -- web/static || true
+sha256sum web/static/main.min.*.css || true
+```
+
+Step two, after the first CI run, is decided by those two lines:
+
+- **Empty `git status` output** — `make css` reproduced the committed bytes
+ exactly, including the content hash in the filename. Nothing about the shipped
+ service changes when the file leaves the index.
+- **A `D` line for `main.min.79713f25.css` plus an untracked
+ `main.min.<other>.css`** — the pipeline produces a different stylesheet than
+ the one committed. That is the interesting case and it wants reading before it
+ is dropped: a hash difference is either drift in `CORE_VER`/`BOOTSTRAP_REV`
+ against whatever the committer had materialized locally, or a genuine change in
+ `scss/main.scss` that was never recompiled. The `sha256sum` line names the new
+ file so the comparison does not depend on scrolling the log.
+
+Either way step two is `git rm --cached web/static/main.min.79713f25.css` and
+nothing else — the ignore patterns are already there. Do it in the first case
+straight away; in the second, reconcile the versions first, because dropping the
+committed file makes CI the only producer and whatever it produces is what
+ships.
+
+Until step two lands, note that `make css` deletes a tracked file during
+`build()`, so the tree is dirty from that point on in the mismatch case. It
+costs nothing today — this service reads no VCS stamp of its own — but it is the
+reason step two should not sit around.
+
+## install-files
+
+`package()` calls `make install-files`, not `make install`.
+
+`abuild` runs `package()` in a **fresh abuild process** under fakeroot, which
+re-sources the APKBUILD and never calls `build()`. So nothing `build()` exported
+reaches it — the `GOCACHE`/`GOMODCACHE` pins in particular. `build` in the
+Makefile is `.PHONY` (Go decides staleness itself; a real file target would never
+rebuild after a source edit), and `install` depends on `build`, so `make install`
+there recompiles the binary from a cold cache. The apk would then ship a second
+compilation that nothing in the pipeline had looked at.
+
+`install-files` is the copying half with no build in front of it: it stages the
+very bytes `build()` produced and `check-css` was asked about. `install -Dm755`
+on a missing `comparesrht` is a fatal error naming the file, which is the right
+report for the one way that target can be called too early.
+
+`install` itself keeps the sub-make (`@$(MAKE) install-files`) rather than
+listing it as a third prerequisite: `/usr/share/abuild/default.conf` exports
+`MAKEFLAGS=-j$(nproc)`, and under `-j` the prerequisites of one target run in
+parallel — the copying would start beside the build it is supposed to follow.
+
+## What is not here
+
+- **No test task in the same commit as the version fix.** They are two commits
+ on purpose: a red suite must not be able to hold up the packaging change.
+- **No coverage upload.** The sibling services POST their profile to cover.sr.ht
+ at the end of the pipeline. Adding it here needs a token secret and a
+ repository on cover.
+- **No artifacts.** There is nothing to download: the apk goes to S3, and its
+ name changes every commit, which `artifacts:` cannot express (it has no
+ globbing).
+- **No matrix.** One architecture, one image.
+
+## Known wart
+
+`make install-files` still runs
+`install -Dm644 -t $(DESTDIR)$(STATICDIR) web/static/*`, which stages
+`bundle.<hash>.js` and `main.min.<hash>.css` into
+`/usr/share/sourcehut/compare.sr.ht/static`. The binary already **embeds** both
+(`//go:embed static` in `web/templates.go:25`) and serves them from there, so
+those copies are dead weight in the apk and a second, divergable source of truth
+for the same bytes. The bench sibling installs no static assets at all for this
+reason. Left alone deliberately — it is not part of this change, and removing it
+wants a check that nothing in the deployment's nginx tree serves that directory
+directly.