From 8255ff90741113fd85e6f15706127bb5c30ca3f5 Mon Sep 17 00:00:00 2001 From: Eugene Blikh Date: Sun, 9 Aug 2026 01:27:37 +0300 Subject: [PATCH] ci: export the version instead of sed-ing a tracked APKBUILD MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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..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. --- .build.yml | 123 +++++++++++++++++--------- .gitignore | 26 +++++- APKBUILD | 38 +++++++-- Makefile | 117 +++++++++++++++++++------ docs/ci.md | 247 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 477 insertions(+), 74 deletions(-) create mode 100644 docs/ci.md diff --git a/.build.yml b/.build.yml index 4b6fe98042d50d32af8a106363d1970b10d0b4a2..4e6cb5c0c1fc6685e97f81ccb8e78f3254410a92 100644 --- a/.build.yml +++ b/.build.yml @@ -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, 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 diff --git a/.gitignore b/.gitignore index 1a860550caf1b8a3c28106cdbb4e7d1b36eb4b20..d9112fc715f1f92c92eac7205d657fa5848c8ed3 100644 --- a/.gitignore +++ b/.gitignore @@ -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..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/ diff --git a/APKBUILD b/APKBUILD index ba12ce9cd5acdb3bc762008bc5f9fc44b170d0d4..c16e94bf6362ca7ee0aa79592a0f736337fc0d87 100644 --- a/APKBUILD +++ b/APKBUILD @@ -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. 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.`. 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 } diff --git a/Makefile b/Makefile index def841a68779ea345073307c27f5e8bad3fbfb09..d965152c1e1fcf47972f40602ed420473bad3209 100644 --- a/Makefile +++ b/Makefile @@ -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) diff --git a/docs/ci.md b/docs/ci.md new file mode 100644 index 0000000000000000000000000000000000000000..2153b61441ec5ed46f932f0320a77503b5e8befb --- /dev/null +++ b/docs/ci.md @@ -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.`. + +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..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. +