~bigbes/sr-ht-spec

8255ff90741113fd85e6f15706127bb5c30ca3f5 — Eugene Blikh 9 days ago 9b5827b
ci: export the version instead of sed-ing a tracked APKBUILD

The version reaches abuild as $PKGVER now, and the tracked APKBUILD is never
rewritten: Go reads vcs.modified from `git status --porcelain`, so a sed of a
tracked file stamps every packaged binary dirty for the whole abuild run.

The export alone would not have fixed it. .gitignore covered the intermediate
web/static/main.css and not the content-hashed main.min.<sha>.css that `make
css` renames it to — the file that survives, and that build() writes BEFORE go
build — so every packaging run compiled with an untracked file in the tree.
/src/, /pkg/ and /tmp/ are the three directories abuild works in inside this
checkout, GOTMPDIR among them.

Also: the cacher bootstrap splits into install + init so the installer's PATH
export is sourced by the next task, and goes through install.sh for its
checksum verification; scss collapses into the --exec miss-and-seed form;
cache_restore takes --optional plus the half-restored module cache repair;
cache_save moves after publish and drops the exists-guards; publish gates on a
readable ~/.apk-ci.env; tags build.

package() stages what build() produced (make install-files) instead of
recompiling both binaries in a fresh fakeroot process with no cache pins, and
stops installing static assets that //go:embed already put in the binary. The
`[ -d ./cmd/... ]` skip guards are gone: a deleted cmd/ was a green build that
packaged nothing.

The reasoning lives in docs/ci.md, because the manifest is a varchar(16384)
and a submission over the cap gets no CI at all.
5 files changed, 477 insertions(+), 74 deletions(-)

M .build.yml
M .gitignore
M APKBUILD
M Makefile
A docs/ci.md
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>