From ec98df318428f0dd27644bb77d19bbeed93f7e48 Mon Sep 17 00:00:00 2001 From: Eugene Blikh Date: Sun, 9 Aug 2026 01:33:46 +0300 Subject: [PATCH] ci: export the version instead of sed-ing the APKBUILD MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The version task rewrote pkgver= in the tracked APKBUILD before abuild ran. Go decides the vcs.modified half of the build stamp it records in every binary from git status --porcelain, so that one sed made every binary in the apk call itself -dirty for the life of the package. Measured on go1.26.5. PKGVER now reaches abuild through ~/.buildenv and the APKBUILD reads pkgver=${PKGVER:-0.0.0}; a local abuild builds an honest 0.0.0. One git describe now decides it — a tag, else tag_git, else the family's 0.0. — and refs/tags/v* joins allow-refs so a tag builds. The export alone is not enough: an untracked file at go build time sets the same flag, and abuild works in this checkout. .gitignore takes /src/, /pkg/ and /tmp/, the last being the one abuild points GOTMPDIR at while MAKEFLAGS=-j runs three go builds that can see each other's work dirs. Around it, the shape the siblings converged on: cacher installed through install.sh (checksum-verified, and the raw curl it replaces had no -f, so a 404 body was chmod +x-ed) in two tasks, since its PATH export lands in ~/.buildenv and only the next task sources it; scss through cacher --exec, whose seed upload is best-effort, instead of an if-block that made an S3 hiccup fatal and a genuine S3 error indistinguishable from a miss; --optional and the module-cache repair block in cache_restore; cache_save after publish, without the cacher exists guards an upload already implies; and publish gated on a readable ~/.apk-ci.env so a secretless submission reports what it built instead of failing. go mod download and NOT go mod download all: all resolves dependencies' test dependencies and appends their hashes to go.sum, which is a tracked file and therefore the very -dirty stamp this commit removes. On this tree it added 426 lines, 671 -> 1097; plain download leaves go.sum untouched and go mod verify still passes. APKBUILD gains GOFLAGS=-trimpath -modcacherw, without which the binaries embed builder paths and the module cache is packed read-only into the cache tarball that the next build then cannot unpack. package() calls the new install-files, which does not build: abuild runs it in a fresh process that never called build(), so make install recompiled all three binaries from a cold cache and shipped a copy nothing had checked. The bare ls of the stylesheet becomes make check-css, which counts — web/router.go takes the first glob match, so two stylesheets are as wrong as none. The three binary targets lose their if [ -d ./cmd/... ] guards, which now only serve to turn a deleted cmd/ into a green build that packages nothing. The manifest is a varchar(16384) and over the cap a branch gets no CI at all, so the reasoning moved to docs/ci.md and the tasks carry pointers. --- .build.yml | 133 +++++++++++++++--------- .gitignore | 22 ++++ APKBUILD | 43 ++++++-- Makefile | 126 ++++++++++++++++++----- docs/ci.md | 292 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 531 insertions(+), 85 deletions(-) create mode 100644 docs/ci.md diff --git a/.build.yml b/.build.yml index 5057ce242e2cff65dc9c14c03365493478ee91d3..670f98283e3ef480c32acc50c886fb5d8d067dc8 100644 --- a/.build.yml +++ b/.build.yml @@ -1,3 +1,11 @@ +# builds.sr.ht manifest for dolt.sr.ht. One linear pipeline: install the cache +# helper, assemble the shared SCSS, restore caches, package with abuild, publish +# the apk, 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 @@ -24,24 +32,23 @@ environment: S3_ENDPOINT: https://s3.bigb.es # Must track the srht deployment's SRHT_CORE_VER, or this service's theme # drifts from the rest of the instance. BOOTSTRAP_REV is the submodule commit - # core.sr.ht pins at that tag; bump the two together. + # core.sr.ht pins at that tag; bump the two together. docs/ci.md#environment. CORE_VER: "0.84.5" BOOTSTRAP_REV: 779ad9f174ea5ab7e755f6df0ec9e5912d67dd16 submitter: git.sr.ht: allow-refs: - refs/heads/master + # Tags build too, now that the version task reads them: pushing v0.2.0 is + # what produces the 0.2.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,52 +72,75 @@ 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 + # every 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 dolt - # dependency tree dominates compile time, and it only changes when go.sum - # does. A miss is just a cold build, never an error. abuild's `go build` - # runs as this same user, so the default ~/go/pkg/mod and ~/.cache/go-build - # locations are picked up without touching the APKBUILD. + # Go module and build caches keyed by go.sum: the dolt dependency tree + # dominates compile time and only changes when go.sum does. --optional + # makes a miss a cold build, not an error. abuild re-pins both, since it + # redirects the Go caches into its own $tmpdir (docs/ci.md#cache_restore). KEY_MOD=$(cacher key "gomod/{hash}.tar.zst" --hash-from "$REPO/go.sum") KEY_GOC=$(cacher key "gocache/{hash}.tar.zst" --hash-from "$REPO/go.sum") echo "export KEY_MOD=$KEY_MOD KEY_GOC=$KEY_GOC" >> ~/.buildenv - # abuild redirects the Go caches into its throwaway $tmpdir (and an - # upstream typo slaves GOMODCACHE to GOCACHE), so env exports here can't - # stick — the APKBUILD's build() re-pins both to these home locations. - cacher dir download "$KEY_MOD" ~/go/pkg/mod || true - cacher dir download "$KEY_GOC" ~/.cache/go-build || true + cacher dir download "$KEY_MOD" ~/go/pkg/mod --optional + cacher dir download "$KEY_GOC" ~/.cache/go-build --optional + # Repair block for the HALF-restored module cache — the normal failure + # here, not a freak one, and it reads like a code bug. Do not remove and + # do not soften to `|| true`: + # docs/ci.md#the-half-restored-module-cache. + cd "$REPO" + chmod -R u+w ~/go/pkg/mod 2>/dev/null || true + if ! go mod verify >/dev/null 2>&1; then + echo "restored module cache did not verify — discarding it" + rm -rf ~/go/pkg/mod + fi + # `go mod download`, NOT `go mod download all`: `all` resolves the whole + # module graph including dependencies' test deps and APPENDS their hashes + # to go.sum, which is a modified tracked file and therefore a "-dirty" + # apk. Measured. docs/ci.md#go-mod-download-and-the-word-all. + go mod download + go mod verify + # Neither line above may rewrite go.mod or go.sum — a dirty tree here is a + # "-dirty" apk, so check rather than trust. docs/ci.md#cache_restore. + git status --porcelain - build: | cd "$REPO" # -d: makedepends are already installed via `packages:` above. 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. 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 @@ -123,10 +151,15 @@ tasks: export RCLONE_CONFIG_GARAGE_ACCESS_KEY_ID="$APK_CI_S3_ACCESS_KEY" export RCLONE_CONFIG_GARAGE_SECRET_ACCESS_KEY="$APK_CI_S3_SECRET_KEY" set -x - # Upload only; never delete. Old versions stay so a pinned deployment can - # always be rebuilt — the same reason the upstream mirror is append-only. + # Upload only, never delete. See docs/ci.md#publish. find "$HOME/packages" -name '*.apk' -print | while read -r f; do rclone copyto "$f" "garage:$S3_BUCKET/$APK_REPO/$(basename "$f")" echo "uploaded $(basename "$f")" done echo "published; apk-mirror on phoebe re-indexes within 15 minutes" + - cache_save: | + # AFTER publish so an S3 hiccup cannot strand a good apk, and fatal on + # purpose. Without --force an upload skips a key already there, so no + # `cacher exists ||` guard is needed. See docs/ci.md#cache_save. + cacher dir upload "$KEY_MOD" ~/go/pkg/mod + cacher dir upload "$KEY_GOC" ~/.cache/go-build diff --git a/.gitignore b/.gitignore index 1b4a8fadec0c156242b7939be6b1f378d03b8307..63463cd30d079affe5f86bd0b58ab6abf93a6d09 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,28 @@ /static/main.min.css /static/main.min.*.css +# The three scratch directories abuild works in. With source="" and +# builddir="$startdir" (see APKBUILD) it has no tarball to unpack elsewhere: it +# unpacks into ./src, stages the package into ./pkg, and defaults tmpdir to +# ./tmp — all three inside the very checkout it is packaging. +# +# Listing them is not tidiness. Go records vcs.modified in every binary built +# inside a repository and reads that flag from `git status --porcelain`, which +# counts UNTRACKED files — so an untracked directory left beside the build makes +# the packaged binary stamp itself "-dirty" for the life of the apk. That +# is the same failure the APKBUILD's pkgver comment is about, arriving by the +# other door: there a tracked file was rewritten, here an untracked one appears. +# +# /tmp/ is the one that is easy to miss and the one that bites hardest. abuild +# exports GOTMPDIR to it, so every `go build` puts its work directory inside +# this checkout, and /usr/share/abuild/default.conf exports MAKEFLAGS=-j$(nproc) +# — under which `make all-bin`'s three `go build`s run at the same time. Each +# reads the tree once while loading packages, before it writes anything, so its +# own work directory is still empty and unreported; the other two are not. +/src/ +/pkg/ +/tmp/ + # Git worktrees, kept inside the repo so they never scatter as sibling dirs. # Both paths, because the two conventions in use across these repos are # .worktrees/ and the older .claude/worktrees/. diff --git a/APKBUILD b/APKBUILD index 9cd0aa7a182900f274ff7318651805106f78675a..1977c6487a965801e76b346f3ca68c8380845cc2 100644 --- a/APKBUILD +++ b/APKBUILD @@ -4,10 +4,21 @@ # 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 and must stay that way. CI used to `sed` +# the literal below in place before calling abuild; that rewrites a tracked file +# inside the checkout, and Go decides half of the VCS stamp it puts into every +# binary — vcs.modified — from exactly that. Measured on go1.26.5: one `sed` of +# APKBUILD makes every binary abuild compiles afterwards record itself as +# "-dirty" for the life of the apk. The export cannot do that. .gitignore +# covers the rest of what abuild writes into this directory. +# +# .build.yml's version task exports PKGVER out of a single `git describe`: a tag +# (v0.2.0 -> 0.2.0), a tag plus commits since it (0.2.0_git7, which sorts AFTER +# the release in Alpine's grammar), or 0.0. when there is no tag — +# which is where this repository stands today. A local `abuild` has no PKGVER +# and builds 0.0.0, which is what a package built by hand honestly is. pkgname=dolt.sr.ht -pkgver=0.0.0 +pkgver="${PKGVER:-0.0.0}" pkgrel=0 pkgdesc="Dolt database hosting for a sourcehut instance" url="https://sourcecraft.dev/bigbes/sr-ht-dolt" @@ -43,16 +54,22 @@ build() { # the default path because sassc/minify aren't always on a dev machine. So # `css` has to be asked for by name; building `all` alone silently produces # a package with no stylesheet. - CGO_ENABLED=0 make all-bin GO_TAGS="$GO_TAGS" + # + # -trimpath so the binaries do not carry this builder's directory layout, + # and -modcacherw for a reason beyond tidiness: without it the module cache + # is left read-only, and the CI cache tarball made from it cannot be + # unpacked on the next build (mkdir into 0555 dirs fails). + CGO_ENABLED=0 make all-bin GO_TAGS="$GO_TAGS" GOFLAGS="-trimpath -modcacherw" # Needs the shared sourcehut scss partials pre-assembled at ASSETS/scss (no # apk ships them); CI does that before calling abuild. make css ASSETS=/usr/share/sourcehut # install-share copies static/*.css under `2>/dev/null || true`, so a # stylesheet that never got built would ship as an unstyled service without - # failing anything. Assert it exists instead. The hashed copy is the one the - # service finds by globbing its static dir at startup. - ls static/main.min.*.css >/dev/null + # failing anything. Assert instead. `make check-css` and not the `ls` that + # stood here: two hashed stylesheets are as wrong as none and quieter, since + # web/router.go resolves the glob and takes the first match. + make check-css } package() { @@ -60,7 +77,17 @@ package() { # This Makefile's install rules do not honour DESTDIR (they write to # $(BINDIR)/$(SHAREDIR) directly), so the staging dir is passed as PREFIX. # MIGRATIONDIR and STATICDIR are both derived from SHAREDIR, so they follow. - make install PREFIX="$pkgdir/usr" + # + # `install-files` and not `install`, and no CGO_ENABLED or GOFLAGS beside it, + # because this function must not compile anything. abuild runs package() in a + # FRESH abuild process under fakeroot (abuild.in's rootpkg), which re-sources + # this file and never calls build(): the cache pins above are gone here and + # cannot be restored — abuild's own GOMODCACHE line overwrites them — so a + # `make install` staged a second compilation of all three binaries, made from + # a cold module cache and with none of the flags above, and it was that copy + # that went into the apk while build()'s checks were left behind with the + # copy nobody shipped. + make install-files PREFIX="$pkgdir/usr" } hook() { diff --git a/Makefile b/Makefile index a6550b88c48771140db339479f2a33fd685287f3..7a361fb945a8d09aa3f1eba020f473ae08d9db96 100644 --- a/Makefile +++ b/Makefile @@ -23,7 +23,17 @@ GO_TAGS?=gms_pure_go CGO_ENABLED?=0 export CGO_ENABLED GO=go -GOBUILD=$(GO) build $(if $(GO_TAGS),-tags "$(GO_TAGS)",) +# The tag flag is a variable of its own because `go build` is not the only +# command that needs it: `go vet` and `go test` type-check the same tree, and a +# vet or a test run without gms_pure_go pulls go-icu-regex in and wants the ICU +# headers the pure-Go build exists to avoid. +GO_TAGSFLAG=$(if $(GO_TAGS),-tags "$(GO_TAGS)",) +GOBUILD=$(GO) build $(GO_TAGSFLAG) + +# The one stylesheet the service looks for at startup: web/router.go resolves +# `main.min.*.css` in its static dir by glob. Named here because check-css and +# clean-share both have to mean exactly the same set of files as that glob. +CSS=static/main.min.*.css BINARIES=\ doltsrht \ @@ -41,42 +51,76 @@ all-share: static/main.min.css css: all-share -# Build each binary if its cmd package exists yet. The cmd/ packages land in -# Phase 3; until then these targets are no-ops rather than hard failures. +# One line each, and no `if [ -d ./cmd/... ]` in front of it. The guard dated +# from Phase 2, when cmd/ did not exist yet and a missing package had to be a +# no-op rather than a hard failure; cmd/ has held all three since Phase 3. What +# the guard does now is turn a deleted or renamed cmd/ directory into a GREEN +# `make all-bin` that produced no binary at all — and, since abuild packages +# whatever is on disk, into an apk quietly missing a program. doltsrht: - @if [ -d ./cmd/doltsrht ]; then \ - echo "$(GOBUILD) -o $@ ./cmd/doltsrht"; \ - $(GOBUILD) -o $@ ./cmd/doltsrht; \ - else \ - echo "skip $@: ./cmd/doltsrht not present yet"; \ - fi + $(GOBUILD) -o $@ ./cmd/doltsrht doltsrht-migrate: - @if [ -d ./cmd/doltsrht-migrate ]; then \ - echo "$(GOBUILD) -o $@ ./cmd/doltsrht-migrate"; \ - $(GOBUILD) -o $@ ./cmd/doltsrht-migrate; \ - else \ - echo "skip $@: ./cmd/doltsrht-migrate not present yet"; \ - fi + $(GOBUILD) -o $@ ./cmd/doltsrht-migrate dolt-git-hook: - @if [ -d ./cmd/dolt-git-hook ]; then \ - echo "$(GOBUILD) -o $@ ./cmd/dolt-git-hook"; \ - $(GOBUILD) -o $@ ./cmd/dolt-git-hook; \ - else \ - echo "skip $@: ./cmd/dolt-git-hook not present yet"; \ - fi + $(GOBUILD) -o $@ ./cmd/dolt-git-hook # Compile every buildable package; used as the CI build gate. build: $(GOBUILD) ./... -install: install-bin install-share - -install-bin: all-bin +# `go vet` and `go test` over the whole module, both with the pure-Go tag, so +# that CI can name them without repeating CGO_ENABLED and GO_TAGS in the +# manifest — a second copy of those two is a second copy to forget. CGO_ENABLED +# is exported above, so both inherit it. +# +# Neither is gated on a DSN: db/'s suites skip themselves when DOLTSRHT_TEST_PG +# is unset, which is what makes `make test` useful on a laptop. The refusal to +# accept that in CI belongs to the manifest's test task, which knows it asked +# for a Postgres. +vet: + $(GO) vet $(GO_TAGSFLAG) ./... + +test: + $(GO) test $(GO_TAGSFLAG) ./... + +# `install` still means "build it, check it, then copy it", which is what a +# person at a checkout wants. The build is a prerequisite and install-files is +# invoked from the recipe rather than listed as a third prerequisite: +# /usr/share/abuild/default.conf exports MAKEFLAGS=-j$(nproc), prerequisites of +# one target run in parallel under -j, and `install: check-css all-bin +# install-files` would let the copying start beside the build it is meant to +# follow. A recipe line always runs after the prerequisites are done. +install: check-css all-bin + @$(MAKE) install-files + +# The copying half of `install`, with nothing to build in front of it. This is +# the target a packaging run calls once it has already built and checked, so +# that what it stages is the very bytes it checked. +# +# That distinction is the whole point of the split. The binary targets above are +# .PHONY (Go decides staleness itself), and abuild runs package() in a FRESH +# abuild process under fakeroot which re-sources the APKBUILD and never calls +# build() — so nothing build() exported reaches it, the CI cache pins included. +# An `install` there recompiled all three binaries from a cold module cache and +# packaged that second compilation, which is not the one anything had inspected. +install-files: install-bin install-share + +# No `if [ -x $$bin ]` around the copy, deliberately, and this is the half of +# the split that makes it safe: now that this target no longer builds, the one +# way to reach it with a missing binary is to call it before a build, and +# `install -Dm755 doltsrht` on a missing file is already a fatal error naming +# the file. The old guard would instead have staged whichever binaries happened +# to be there and returned 0. +# +# `|| exit 1` because a `for` loop reports only its LAST command: without it a +# missing doltsrht followed by two binaries that copied fine is a green +# install-bin, which is the same silence spelled differently. +install-bin: mkdir -p $(BINDIR) for bin in $(BINARIES); do \ - if [ -x $$bin ]; then install -Dm755 $$bin $(BINDIR)/; fi; \ + install -Dm755 $$bin $(BINDIR)/ || exit 1; \ done install-share: @@ -86,15 +130,43 @@ install-share: install -Dm644 migrations/*.sql $(MIGRATIONDIR) if [ -d static ]; then install -Dm644 static/*.css static/*.svg $(STATICDIR) 2>/dev/null || true; fi +# The packaging gate: no stylesheet, no package. It exists because the failure +# it catches is invisible at build time — install-share copies static/*.css +# under `2>/dev/null || true`, so a `make css` that produced nothing stages a +# service with no stylesheet and fails nothing; the first sign of trouble is an +# unstyled page in production. +# +# It counts the matches rather than asking whether there are any, because TWO +# stylesheets are as wrong as none and quieter: web/router.go resolves $(CSS) by +# glob and takes the first match, so a second file makes the served stylesheet +# depend on readdir order. `css` does not remove the previous build's hashed +# file, which is exactly why this 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 below, out of a hex digest. +check-css: + @set -- $$(ls $(CSS) 2>/dev/null); \ + if [ $$# -eq 0 ]; then \ + echo "error: no $(CSS) — run 'make css' (sassc and minify required)"; \ + exit 1; \ + elif [ $$# -gt 1 ]; then \ + echo "error: $$# files match $(CSS) — web/ takes the first, so the choice is arbitrary."; \ + echo " run 'make clean-share && make css' to get back to one:"; \ + for f in "$$@"; do echo " $$f"; done; \ + exit 1; \ + fi + clean: clean-bin clean-share clean-bin: rm -f $(BINARIES) clean-share: - rm -f static/main.min.css static/main.css static/main.min.*.css + rm -f static/main.min.css static/main.css $(CSS) -.PHONY: all all-bin all-share css build install install-bin install-share +.PHONY: all all-bin all-share css check-css build vet test +.PHONY: install install-files install-bin install-share .PHONY: clean clean-bin clean-share $(BINARIES) static/main.css: scss/main.scss diff --git a/docs/ci.md b/docs/ci.md new file mode 100644 index 0000000000000000000000000000000000000000..af2c8c4316cd1c58dfef17715b95019c795fb889 --- /dev/null +++ b/docs/ci.md @@ -0,0 +1,292 @@ +# CI: what `.build.yml` does and why + +The manifest is deliberately short on prose. builds.sr.ht stores a submitted +manifest in a `varchar(16384)`, so a manifest that grows past 16 KiB cannot be +submitted at all — the failure is at submission time and reads like nothing in +particular. 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, decide a version, restore caches, package with +`abuild`, publish the apk to `repo.bigb.es/alpine/v3.22/bigbes`, save 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` build the stylesheet; `curl` installs `cacher`; `rclone` +publishes the apk; `abuild`, `go` and `git` are the build itself. + +There is no `postgresql` here and no compiler toolchain beyond `go`: this +package is built `CGO_ENABLED=0` with `-tags gms_pure_go` (see the APKBUILD and +the Makefile), which is what keeps `libicu` and `gozstd` off the builder +entirely. A default cgo build of this tree fails on missing ICU headers and +always has. + +## secrets + +Three, all account-level and shared with the sibling services: + +| secret | lands at | used by | +|---|---|---| +| `apk-ci-s3` | `~/.apk-ci.env` | `publish` | +| `7dde4219-…` | `~/.s3-cache-key-id` | `cacher_init` | +| `0e5b3530-…` | `~/.s3-cache-key-secret` | `cacher_init` | + +They are file secrets. Listing them is what turns `publish` and the cache tasks +on; a manual submission that asks for no secrets still runs the interesting part +of the pipeline and stops at `cacher_init`, which is the right place to notice. + +`apk-ci-s3` is referenced by name and the other two by UUID, which is only +because that is how they were written in the donor manifests; both forms work. + +## environment + +`CORE_VER` must track the deployment's `SRHT_CORE_VER`, and `BOOTSTRAP_REV` is +the Bootstrap 4 submodule commit `core.sr.ht` pins at that tag. The two being +out of step means this service renders against different partials than the rest +of the instance, which shows up as a page that is subtly the wrong shape and as +nothing at all in any log. Bump them together. + +## 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 `install.sh` and not a raw `curl` of the binary, for two reasons that both +end in the same place. The installer verifies the download against the published +`checksums.txt`; and the raw `curl` it replaced had no `-f`, so an S3 404 or a +proxy error page was written to `~/.local/bin/cacher`, `chmod +x`-ed, and only +noticed as a baffling "not found"/"exec format error" one task later. + +## 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. + +The `--exec` block runs only on a cache miss, and `cacher` seeds the cache from +the result afterwards. It sees exported variables only, which is why the cache +key is spelled inline in the `cacher` invocation rather than computed inside the +block. + +`--exec` and not the `if ! cacher dir download …; then … fi` this task used to +be. The `if` form conflates two different failures into one branch: a genuine S3 +error and an ordinary cache miss both mean "clone it again", so an S3 outage was +silently absorbed into a slower build and never reported. And it made the seed +upload the last command of the branch, i.e. fatal — an S3 hiccup while *storing* +a cache would fail a build whose SCSS had assembled perfectly. Under `--exec` +the seeding is best-effort by construction and the download's own errors stay +distinguishable from a miss. + +## keygen + +`abuild-keygen -a -n -i -q` makes a throwaway package signing key. `-i` is not +optional: without it the key is generated but not installed into +`/etc/apk/keys`, and `abuild` later fails to index its own output with an +UNTRUSTED signature error, after having built the package perfectly well. + +The key is per-build and thrown away with the VM. That is fine because nothing +verifies these signatures — the apk index on `repo.bigb.es` is signed by +`apk-mirror` on phoebe, which indexes this repo with `--allow-untrusted` +precisely because of this. + +## version + +One `git describe` decides the apk's `pkgver`: + +- a tag — `v0.2.0` becomes `0.2.0`; +- a tag plus commits since it — `v0.2.0-7-gabc1234` becomes `0.2.0_git7`. Alpine's + version grammar does not accept the raw describe output, and `_git` sorts + **after** the release in Alpine's comparison, which is what makes an untagged + master build look newer than the tag it follows; +- no tag at all — `0.0.`, which is where this repository stands + today and is the scheme of the bench/spec/compare siblings. + +Tags are in `allow-refs` for exactly this reason: pushing `v0.2.0` is what +produces the `0.2.0` apk. + +### Why it is exported and not `sed`-ed + +`PKGVER` reaches `abuild` through `~/.buildenv`, and `APKBUILD` reads +`pkgver="${PKGVER:-0.0.0}"`. It used to `sed` the literal in the tracked +`APKBUILD` instead. **Do not tidy it back.** + +Go records a VCS stamp in every binary it builds inside a repository, and it +decides the `vcs.modified` half of that stamp from `git status --porcelain`. +Rewriting a tracked file in the checkout sets it, and it stays set for the whole +`abuild` run — so every binary in the apk records itself as `-dirty`. +Measured on go1.26.5. + +The same flag is why `.gitignore` lists `/src/`, `/pkg/` and `/tmp/`: `git +status --porcelain` counts **untracked** files too, and abuild — which works in +this checkout, since `APKBUILD` sets `builddir="$startdir"` and has no tarball +to unpack — creates all three inside it. `/tmp/` is the one that is easy to +miss: abuild defaults `tmpdir` to `$startdir/tmp` and exports `GOTMPDIR` to it, +and `/usr/share/abuild/default.conf` exports `MAKEFLAGS=-j$(nproc)`, under which +`make all-bin`'s three `go build`s run at once and each sees the others' work +directories. + +The task ends with `git status --porcelain` because this is the last moment the +tree is provably clean, and the print costs nothing. + +## cache_restore + +The Go module cache and build cache are keyed by the hash of `go.sum`, so a +build that changed no dependency reuses both. That matters more here than in the +siblings: the `dolthub/dolt` dependency tree dominates compile time and only +moves when `go.sum` does. `--optional` turns a miss into a cold build rather +than a failure — it replaced a `|| true`, which also swallowed a genuine S3 +error and an unreadable credential. + +`abuild` redirects `GOCACHE` into its own `$tmpdir` (and an upstream typo slaves +`GOMODCACHE` to `GOCACHE`'s value rather than its own), so exports here cannot +stick. `APKBUILD`'s `build()` re-pins both to these home locations, which is +what makes the tarballs saved below the same trees 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 `go mod 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. + +### `go mod download` and the word `all` + +It is `go mod download`, **not** `go mod download all`, and the difference is +not a matter of thoroughness. + +`all` is the package pattern meaning the whole module graph reachable from this +one — dependencies' test dependencies included, which no build of this module +ever compiles. Resolving it makes the toolchain **append** the missing hashes to +`go.sum`, and `go.sum` is a tracked file. A tracked file modified in the +checkout before `go build` runs is exactly the `-dirty` stamp the `version` task +above exists to avoid: the task written to protect the version would have been +the thing that broke it. + +Measured on this tree with go1.26.5: `go mod download all` took `go.sum` from +671 lines to 1097 — 426 hashes added — and left the tree modified. Plain `go mod +download` left `go.sum` byte-identical, and `go mod verify` answered "all +modules verified" after it. This repository's `dolthub/dolt` graph is the +largest in the family, so the gap here is the widest; it is not zero anywhere. + +The same failure was seen for real on a sibling: bench build #359 failed its +version gate with `M go.sum` as the only thing in the way. + +## 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() and package() + +`build()` compiles with `GOFLAGS="-trimpath -modcacherw"` and then asserts with +`make check-css`; `package()` calls `make install-files`, which **does not +build**. + +That split is the point. abuild runs `package()` in a fresh abuild process under +fakeroot which re-sources the `APKBUILD` and never calls `build()`, so nothing +`build()` exported survives — the cache pins above included. A `make install` +there recompiled all three binaries from a cold module cache, without +`-trimpath`, and it was *that* copy that went into the apk, while the checks +`build()` had run were left behind with the copy nobody shipped. + +`-modcacherw` matters beyond tidiness: without it the module cache is left +read-only, and the cache tarball `cache_save` makes from it cannot be unpacked +on the next build (`mkdir` into `0555` directories fails). + +`make check-css` replaced a bare `ls static/main.min.*.css`. `install-share` +copies stylesheets under `2>/dev/null || true`, so a `make css` that produced +nothing would stage an unstyled service and fail nothing; and `web/router.go` +resolves `main.min.*.css` by glob and takes the first match, so **two** hashed +stylesheets are as wrong as none and quieter. `check-css` counts. + +`PREFIX="$pkgdir/usr"` and not `DESTDIR`: this Makefile's install rules write to +`$(BINDIR)`/`$(SHAREDIR)` directly and genuinely do not honour `DESTDIR`. + +## publish + +The `[ ! -r ~/.apk-ci.env ]` gate is the honest answer to a build that was handed +no secrets, not a fallback. A manual submission without secrets still runs every +earlier task and still leaves a built, signed apk in `$HOME/packages`; saying so +and exiting 0 is more useful than a red build about a credential nobody meant to +supply. 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. Old versions stay +so a pinned deployment can always be rebuilt. + +`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 signed 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 the uploads any more. +`cacher dir upload` without `--force` already skips a key that is present, so +the guard bought nothing and cost a second round trip whose failure mode — a +transient error on the `exists` probe — was an unnecessary re-upload. + +## The `hook` subpackage + +`dolt.sr.ht-hook` carries `dolt-git-hook` alone. It is installed into the +**git.sr.ht** container as its post-update-script, not into the dolt one, and +pulling the full service package in there would drag `doltsrht` and its whole +dependency closure along. + +Nothing in this wave changed it, and that is deliberate: the git.sr.ht container +consumes it at a pinned version. But it is built from the same `APKBUILD` — the +`hook()` function only `amove`s a file the main package already staged — so it +is versioned in lockstep with `dolt.sr.ht` and inherits everything above. + +Two consequences worth stating out loud. Its `pkgver` now comes from the +exported `PKGVER` like the parent's, so the first tagged build renames it from +`dolt.sr.ht-hook-0.0.` to `dolt.sr.ht-hook-` and a deployment pinning +the old name must be bumped along with it. And `dolt-git-hook` is one of the +three binaries `build()` now compiles with `-trimpath`, so the file the +subpackage ships changes bytes on the next build even where nothing else did. + +## 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. +- **No `gofmt` gate.** `web/beads.go` and four test files are gofmt-dirty on + master; a gate would be red on arrival. `go vet` runs instead.