M .build.yml => .build.yml +83 -40
@@ 1,3 1,11 @@
+# builds.sr.ht manifest for spec.sr.ht. One linear pipeline: install the cache
+# helper, assemble the shared SCSS, stamp a version, restore caches, start a
+# Postgres in the VM, test, package with abuild, publish, save 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
@@ 31,17 39,16 @@ submitter:
git.sr.ht:
allow-refs:
- refs/heads/master
+ # Tags build too, now that the version task reads them: pushing v0.9.0 is
+ # what produces the 0.9.0 apk. See docs/ci.md#version.
+ - "refs/tags/v*"
tasks:
- - 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.
- # First task, so the scss assembly below can already use the cache.
- 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; installed first so scss can already use it.
+ # install.sh's PATH export goes to ~/.buildenv, which only the NEXT task
+ # sources — hence two tasks and not one. 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 \
@@ 50,14 57,12 @@ tasks:
--key-file ~/.s3-cache-key-id \
--secret-file ~/.s3-cache-key-secret
- scss: |
- # No apk ships the shared sourcehut SCSS partials, so assemble them the
- # way core.sr.ht's `make install-scss` would: its own scss/ plus the
- # Bootstrap 4 submodule. `make css` runs sassc -I against this tree.
- # The assembled tree is cached keyed by the two pins — on a hit this
- # task touches neither git.sr.ht nor github.com, so their outages can't
- # fail the build.
- KEY_SCSS="scss/${CORE_VER}-${BOOTSTRAP_REV}.tar.zst"
- if ! cacher dir download "$KEY_SCSS" ~/scss; then
+ # 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
@@ 67,27 72,32 @@ tasks:
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
- cacher dir upload "$KEY_SCSS" ~/scss
- fi
+ '
sudo mkdir -p /usr/share/sourcehut
sudo cp -r ~/scss /usr/share/sourcehut/scss
- 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.
+ # 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 apk pkgver: a tag, else tag_git<n>, else
+ # the family's commit count. The raw describe output is not a legal
+ # pkgver and _git sorts AFTER the release: docs/ci.md#version.
+ #
+ # EXPORTED rather than sed-ed into the APKBUILD (which reads $PKGVER)
+ # because rewriting a tracked file would flip the VCS stamp Go records in
+ # the binary to dirty — do not "tidy" it back into a sed. The tree is
+ # printed because this is the last moment it is provably clean.
cd "$REPO"
- ver="0.0.$(git rev-list --count HEAD)"
- sed -i "s/^pkgver=.*/pkgver=$ver/" APKBUILD
+ 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"
+ 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
@@ 98,19 108,46 @@ tasks:
# 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
+ # --optional makes a miss a cold build, not an error.
+ 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` and NOT `go mod download all`: the `all` pattern
+ # resolves the whole module graph, test dependencies of dependencies
+ # included, and APPENDS their hashes to the tracked go.sum — 170 lines on
+ # this tree, measured. A modified tracked file is a "-dirty" apk, which is
+ # the failure this whole commit is about. docs/ci.md#cache_restore.
+ go mod download
+ go mod verify
+ # And the tree is printed because the two lines above are the last thing
+ # that touches it before abuild does. docs/ci.md#cache_restore.
+ git status --porcelain
- build: |
cd "$REPO"
- # -d: makedepends are already installed via `packages:` above.
+ # -d: makedepends come from `packages:`. The APKBUILD runs `make css`
+ # before `make build` and asserts the result with `make check-css`.
+ # 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 that was handed no secrets, not
+ # a fallback: with ~/.apk-ci.env absent every earlier task has still run
+ # and a signed apk is sitting in $HOME/packages. On a push the secret is
+ # there and this publishes. 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
@@ 128,3 165,9 @@ tasks:
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 +24 -2
@@ 1,6 1,19 @@
-# Compiled service binaries
+# The two binaries `make build` writes into the repository root, and 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 live inside the checkout it is packaging, and abuild exports
+# GOTMPDIR=$startdir/tmp, so every `go build` puts its work directory there.
+#
+# 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 stamps a released binary "-dirty" for the life of the apk.
/specsrht
/specsrht-migrate
+/src/
+/pkg/
+/tmp/
# Transient build artifacts
*.tmp
@@ 18,8 31,17 @@
/repos/
/cache/
-# Intermediate stylesheet; only the content-hashed main.min.<sha>.css ships.
+# The stylesheet is build output, not source: `make css` regenerates it from
+# scss/main.scss and a `base` partial that belongs to the deployment's
+# core.sr.ht version, so committing it would pin the chrome to whichever
+# version its author happened to have materialized.
+#
+# BOTH names have to be here. Only the intermediate main.css was, and the
+# content-hashed file `make css` renames it to is the one that survives — it is
+# written by the APKBUILD's build() BEFORE `go build`, so every packaging run
+# used to compile the binaries with an untracked file sitting in the tree.
/web/static/main.css
+/web/static/main.min.*.css
# Beads / Dolt files (added by bd init)
.dolt/
M APKBUILD => APKBUILD +33 -5
@@ 4,10 4,20 @@
# 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.
+# pkgver is READ FROM THE ENVIRONMENT, not rewritten in place. CI's `version`
+# task exports PKGVER from one `git describe`: a tag becomes `0.2.0`, a tag plus
+# commits becomes `0.2.0_git7` (which sorts AFTER the release in Alpine's
+# comparison), and a repository with no tags at all falls back to
+# `0.0.<commit count>`. The literal below is what a local `abuild` that was
+# handed no PKGVER honestly builds.
+#
+# The earlier arrangement had CI `sed` that literal here before abuild ran, and
+# it cannot stand: Go records vcs.modified in every binary compiled inside a
+# repository and reads that flag from `git status --porcelain`, so a CI task
+# that rewrites a tracked file in the checkout stamps every packaged binary
+# dirty for the life of the apk. Measured on go1.26.5.
pkgname=spec.sr.ht
-pkgver=0.0.0
+pkgver="${PKGVER:-0.0.0}"
pkgrel=0
pkgdesc="Reviewable document storage for humans and agents"
url="https://sourcecraft.dev/bigbes/sr-ht-spec"
@@ 33,6 43,12 @@ build() {
# shared scss partials are assembled by CI at ASSETS/scss (no apk ships
# them).
make css ASSETS=/usr/share/sourcehut
+ # The stylesheet has to exist before the compiler runs and nothing else
+ # says so: `go build` succeeds perfectly well with an unstyled static/,
+ # because //go:embed takes the directory and not the file, and the first
+ # sign of trouble would be an unstyled page in production. `make install`
+ # runs this too; here it fails the build before anything is staged.
+ make check-css
# -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).
@@ 42,6 58,18 @@ build() {
package() {
cd "$builddir"
# This Makefile honours DESTDIR; ASSETS must stay the real runtime path so
- # migrations and schema land where specsrht-migrate resolves them.
- make install DESTDIR="$pkgdir" PREFIX=/usr ASSETS=/usr/share/sourcehut
+ # migrations and schema land where specsrht-migrate resolves them. Static
+ # assets are not installed at all — they are inside the binary, embedded by
+ # web/templates.go.
+ #
+ # `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(): nothing build() exported
+ # reaches here, the Go cache pins above included, so `make install` — whose
+ # .PHONY binary targets always rerun — relinked both binaries from a cold
+ # cache and shipped a SECOND binary that nothing in this pipeline had
+ # tested. `install -Dm755 specsrht` on a missing file is a fatal error
+ # naming it, so a package() reached without a build() says so.
+ make install-files DESTDIR="$pkgdir" PREFIX=/usr \
+ ASSETS=/usr/share/sourcehut
}
M Makefile => Makefile +90 -27
@@ 17,35 17,44 @@ BINDIR?=$(PREFIX)/bin
# /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
-STATICDIR?=$(ASSETS)/$(SERVICE)/static
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. cmd/ lands in Phase 1's Wave B, so until then each
-# target skips rather than failing — a red default target during the build-out
-# trains everyone to ignore it.
+# 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):
- @if [ -d ./cmd/$(BIN) ]; then \
- echo "go build -o $@ ./cmd/$(BIN)"; \
- go build -o $@ ./cmd/$(BIN); \
- else \
- echo "skip $@: ./cmd/$(BIN) not present yet"; \
- fi
+ go build -o $@ ./cmd/$(BIN)
$(MIGRATE_BIN):
- @if [ -d ./cmd/$(MIGRATE_BIN) ]; then \
- echo "go build -o $@ ./cmd/$(MIGRATE_BIN)"; \
- go build -o $@ ./cmd/$(MIGRATE_BIN); \
- else \
- echo "skip $@: ./cmd/$(MIGRATE_BIN) not present yet"; \
- fi
+ go build -o $@ ./cmd/$(MIGRATE_BIN)
test:
go test ./...
@@ 74,21 83,75 @@ run-dev: build
# 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.
-install: build
- install -Dm755 $(BIN) $(DESTDIR)$(BINDIR)/$(BIN)
- install -Dm755 $(MIGRATE_BIN) $(DESTDIR)$(BINDIR)/$(MIGRATE_BIN)
+#
+# 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)
- @if [ -d web/static ]; then \
- mkdir -p $(DESTDIR)$(STATICDIR); \
- install -Dm644 -t $(DESTDIR)$(STATICDIR) web/static/*; \
- else \
- echo "skip static assets: web/static not present yet"; \
+ $(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 css run-dev install clean $(BIN) $(MIGRATE_BIN)
+.PHONY: all build test css run-dev install install-files check-css clean \
+ $(BIN) $(MIGRATE_BIN)
A docs/ci.md => docs/ci.md +247 -0
@@ 0,0 1,247 @@
+# 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, it reads like nothing in
+particular, and what it produces is a branch with no CI rather than a red build.
+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, stamp a 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 the stylesheet pipeline (`make css`), and they are
+build-time only: the compiled CSS is embedded into the binary by `//go:embed`,
+so nothing at runtime needs either.
+
+`rclone` is for `publish` and `curl` for the cacher bootstrap.
+
+## 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 `publish` says so and exits 0 rather than failing.
+
+`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.
+
+## cacher
+
+`cacher` is an S3-backed cache helper, installed from 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 it and then not find it.
+
+It is installed through `install.sh` rather than by `curl`-ing the raw binary:
+the installer verifies the download against the published `checksums.txt`, and
+`curl -fsSL` fails on an HTTP error instead of writing the error page to the
+destination and marking it executable.
+
+## 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`, cached under a
+key made of the two pins so an outage at git.sr.ht or GitHub cannot fail a build
+that changed nothing.
+
+**`CORE_VER` must track the deployment's `SRHT_CORE_VER`.** The two being out of
+step means this daemon 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. `BOOTSTRAP_REV` is the submodule commit core.sr.ht pins at
+that tag; bump the two together.
+
+The `--exec` form is the cache helper's own miss-and-seed: the block runs only
+on a miss and the directory is uploaded afterwards, which is one flow instead of
+an `if ! cacher dir download … fi` with a matching upload inside it that has to
+be kept in step by hand. It 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.
+
+The cache key here is not the `--prefix` `cacher_init` sets. That one namespaces
+this repository's Go caches; the SCSS tree is keyed by the two upstream pins and
+nothing about this repository, so it is deliberately not hoisted into a shared
+prefix.
+
+## keygen
+
+`abuild-keygen -a -n -i -q` makes a throwaway package signing key: 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` 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
+dies with an UNTRUSTED signature after having built the package perfectly well.
+
+## 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 it falls back to `0.0.<commit count>`.
+
+Tags are in `allow-refs` for exactly this reason: pushing `v0.9.0` is what
+produces the `0.9.0` apk.
+
+### Why it is exported and never `sed`-ed
+
+The version reaches `abuild` as an environment variable — `APKBUILD` reads
+`pkgver="${PKGVER:-0.0.0}"` — and the tracked `APKBUILD` is never rewritten.
+
+This is not style. Go records `vcs.modified` in every binary compiled inside a
+git repository and reads that flag from `git status --porcelain`. A CI task that
+`sed`s a tracked file leaves the tree dirty for the whole `abuild` run, so
+`Main.Version` in the packaged binary carries `+dirty` for the life of the apk.
+Measured on go1.26.5.
+
+The same measurement is why `.gitignore` matters here more than tidiness would
+suggest: `git status --porcelain` counts **untracked** files too. `make css`
+writes `web/static/main.min.<sha>.css` *before* `go build` runs, and that name
+was not ignored — only the intermediate `main.css` was — so every packaging run
+compiled the binaries with an untracked file sitting in the tree. `/src/`,
+`/pkg/` and `/tmp/` are the three directories `abuild` works in with
+`source=""` and `builddir="$startdir"`, all of them inside the checkout being
+packaged, and abuild exports `GOTMPDIR=$startdir/tmp` on top of that.
+
+The `version` task ends with `git status --porcelain` because this is the last
+moment the tree is provably clean, and everything that could dirty it happens
+after.
+
+## 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` turns a miss into a
+cold build rather than a failure — `|| true` did the same thing while also
+swallowing a real error, such as a bad endpoint or expired credentials.
+
+`abuild` redirects both caches into its throwaway `$tmpdir` (and an upstream
+typo slaves `GOMODCACHE` to `GOCACHE`), so exports from this task cannot stick:
+the `APKBUILD`'s `build()` re-pins both to the home locations restored here.
+
+### 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 download 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.
+
+### Why the download is not `go mod download all`
+
+`all` is not a harmless "be thorough" flag. It resolves the entire module
+graph — the test dependencies of dependencies included — and **appends their
+hashes to the tracked `go.sum`**: 170 lines on this tree, measured with
+go1.26.5. A modified tracked file in the checkout at `go build` time is exactly
+the dirty stamp the [version](#why-it-is-exported-and-never-sed-ed) section is
+about, so the task written to warm the cache would have undone the fix.
+
+Plain `go mod download` leaves `go.sum` byte-identical and `go mod verify` still
+passes after it, which is the whole of what this task needs: the build imports
+what the main module imports, and those hashes are already in `go.sum` because
+they are what `go.sum` is.
+
+This is not theoretical either — it is what the sibling bench.sr.ht build #359
+failed on, at its `check-version` gate, with `M go.sum` named as the culprit.
+
+## 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 `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`, then `make check-css`, then `make build`. The order is
+forced by `//go:embed`: `web/templates.go` embeds the whole `static` directory,
+so a stylesheet built after the compiler ran is a stylesheet no binary contains.
+`go build` succeeds perfectly well with an unstyled `static/` — the embed takes
+the directory, not the file — so `check-css` is the only thing between that
+mistake and an unstyled service in production.
+
+`package()` calls `make install-files`, not `make install`, and this is the one
+line in the file that looks like a micro-optimisation and is not. abuild runs
+`package()` in a **fresh** abuild process under fakeroot, which re-sources the
+`APKBUILD` and never calls `build()`. Nothing `build()` exported reaches it — the
+Go cache pins included — and the binary targets are `.PHONY`, so `make install`
+relinked both binaries from a cold module cache. The apk therefore shipped a
+*second* binary, compiled in a different environment, that no task in this
+pipeline had ever tested. `install-files` is the copying half of `install` with
+no build in front of it, so what is packaged is the bytes `build()` produced.
+
+Static assets are not installed by either target. They are inside the binary; a
+second copy under `$(ASSETS)/spec.sr.ht/static` is dead weight in the apk that
+nothing reads.
+
+## publish
+
+The `[ ! -r ~/.apk-ci.env ]` gate is the honest answer to a build that was handed
+no secrets, and not a fallback: every earlier task has still run and a signed apk
+is sitting in `$HOME/packages`, so the useful part of a manual submission has
+happened and only the upload cannot. 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.
+
+`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 any more.
+Without `--force` an upload skips a key that is already there, so the guard was
+a second round-trip that asked the question the upload asks anyway — and one
+that goes wrong in the direction that matters, since a key that exists but is
+truncated is exactly the half-restored cache the repair block above is about.
+
+## What is not here
+
+- **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.
+</content>