# 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, start a Postgres, test (with coverage), package with `abuild`, publish the apk to `repo.bigb.es/alpine/v3.22/bigbes`, save caches, upload the coverage profile to cov.sr.ht and this build's own benchmarks to bench.sr.ht. 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. `postgresql` and `postgresql-client` are for the test suite, not for the package. The apk declares no runtime dependency on Postgres — the daemon talks to whatever `connection-string` names, which in production is another host. There is 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 Four, 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` | | `c7968415-…` | `~/.srht-token` | `publish_artifacts`, `coverage`, `bench` | 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. `~/.srht-token` holds a tokens.sr.ht **working token**, and it is one secret shared with every sibling service rather than a per-service one. That is what centralising issuance buys: the credential is minted once, for a person, and carries the grants of every service it is meant to reach — so this one must carry `artifacts:upload` for `publish_artifacts`, `cov:upload` for `coverage` and `bench:upload` for `bench`. Grants are compared literally, so a token missing one of the three fails that one task and no other; there is no partial credit and no fallback. ## 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. `COVER_REPO` and `BENCH_REPO` are both `~bigbes/sr-ht-dolt`, and that is not a guess from the checkout directory: it is the `sources:` line (`https://git.srht.bigb.es/~bigbes/sr-ht-dolt`) read as `~owner/repo`. The directory this repository is cloned into is `sourcehut-dolt` on at least one machine, which is exactly the name that would have been wrong. ## artifacts Two, both literal paths relative to `$HOME`: `cover.out` (written by `test`) and `bench.txt` (written by `bench`). `artifacts:` has no globbing, which is why the `Makefile` takes `COVERPROFILE` as a variable — CI points it at `$HOME` and a checkout leaves it in the checkout. They are **not** a fallback for the two uploads. They are what a build handed no secrets still leaves behind, so a manual submission that asked for none can still be read, and a rejected upload can be replayed by hand from the exact bytes the build produced. ## 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. ## postgres A real Postgres in the VM, initialised from scratch each build. Until this task existed, **not one of this repository's 28 test files had ever run on the builder** — the manifest went straight from `cache_restore` to `abuild`, and `options="!check"` in the `APKBUILD` meant abuild did not run them either. Every apk this pipeline has published was built from code CI had compiled and never executed. What the database needs to be there for, measured on this tree: `db/` has 10 tests and 9 of them gate themselves on `DOLTSRHT_TEST_PG` (`db/db_test.go`'s `newTestStore`), so without a DSN the entire persistence layer — repository and key CRUD, the access-control queries, the schema — is skipped and the build is green regardless. The other packages do not need it and do not skip without it. The suites do not share state: each creates a scratch schema `doltsrht_test_`, applies `schema.sql` into it, routes its pool there with lib/pq's `options=-c search_path=…`, and drops it afterwards. So one database serves the whole suite, and no `CREATE DATABASE` privilege is needed past the one `createdb` above. `fsync=off`, `full_page_writes=off` and `synchronous_commit=off` are safe here and only here: the database lives for the length of one build and its durability guarantees protect nothing. `-k /run/postgresql` is why the task creates that directory — Alpine's package does not — and `createuser -s "$(id -un)"` is what lets the build user connect without a password, which is what keeps the DSN in `~/.buildenv` free of a credential. ### `remoteapi/integration_test.go` does not want Docker That file's `startPostgres` prefers `DOLTSRHT_TEST_PG` and falls back to `docker run postgres:16-alpine` only when the variable is empty, so with the DSN exported it never looks for a Docker daemon the builder does not have. It is moot in any case: the file is behind `//go:build integration`, so `go test ./...` does not even compile it. Running it would additionally need the `dolt` CLI, which it skips on. `storage/spike_test.go` is behind `//go:build spike` for the same kind of reason. Neither tag is set here, deliberately. ## test `make vet` and `make cover`, not bare `go` commands: the Makefile is where `-tags gms_pure_go` and `CGO_ENABLED=0` are named, and a second copy of those two here is a second copy to forget. The tags are not optional — a `go vet` or `go test` without `gms_pure_go` pulls `go-icu-regex` in and wants ICU headers the builder has never had. It is `make cover` rather than `make test` because the two run the same suites over the same tree; `cover` only adds `-covermode=atomic -coverprofile=…`. Doing it in one task means the profile is a by-product of the gate that already had to pass, and not a second full run of the suites whose result nothing checks. `atomic` and not the default `set`: the profile carries real hit counts, which is what a trend across commits is read off, and `set` would flatten every count to a bit. `COVERPROFILE="$HOME/cover.out"` because that is where `artifacts:` looks, and because `abuild` packages this checkout in place: a profile written into the checkout is one more file in the tree the `version` task had just proved clean. The guard on an empty `DOLTSRHT_TEST_PG` exists because the failure it prevents is silent. If the `postgres` task did not export the DSN — or if someone reorders the two tasks — every database suite skips with a friendly message, `go test` exits 0, and the build is green over untested code. An explicit refusal is the difference between a broken pipeline and a lying one. It is also what makes `options="!check"` in the `APKBUILD` an honest claim rather than a licence: that option says "the suites ran in CI", and this guard is the only thing that keeps it true. There is no `gofmt` gate, unlike the tokens sibling. `web/beads.go` and four test files are gofmt-dirty on master, so a gate would be red on arrival; fixing them is a separate change and not one to smuggle into a CI wave. The task runs **before** `build`, so the tree it leaves behind is the tree `abuild` packages; the closing `git status --porcelain` is where anything the suites wrote into the checkout would show. ## 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. ## publish_artifacts The same apk, uploaded a second time — into the `~bigbes/main` channel of `artifacts.sr.ht`, which indexes and signs it in the same request rather than on a 15-minute timer. One `PUT` per file to `/api/v1/pkg/~bigbes/main/apk/v3.22`, with the working token from `~/.srht-token` as the bearer. No client binary is involved: the upload route takes the file as the request body, and `curl` is already installed for the cacher. It is a task of its own and not two more lines inside `publish` for one reason: the S3 copy is what phoebe re-indexes today and this one is the road being opened. A refusal here has to be legible as *this* destination refusing, and it must not be able to undo an upload that already succeeded. `set +x` around every `curl`, for the same reason `publish` has it: the token is on the command line and the log is world-readable. Three answers are expected and only two of them are good: - `201` — published, indexed, signed. - `200` — the same version with the same bytes was already there. A rerun of a job that got as far as this task is idempotent, and stays green. - `409` — the same version with *different* bytes. `abuild` stamps mtimes into the archive, so resubmitting one commit produces a byte-different apk under an identical `pkgver`; a published version is immutable, so the daemon keeps what it has. That is a `WARNING` and not a failure — it means the build was run twice, not that anything is wrong. Republishing means removing the version through the API first. Anything else prints the daemon's error body and fails the task, after trying every remaining file: one bad package must not hide the fate of the others. The loop is `for file in $(find …)` rather than `find … | while read`, because under ash the second form runs its body in a subshell and the failure flag set in it is lost when the pipeline ends — the task would report success it never had. Gated on `~/.srht-token` being readable, exactly as `publish` is gated on `~/.apk-ci.env`: a manual submission without secrets still builds the apk, and says that nothing was published. Installing what it publishes, on any Alpine box: ```sh wget -qO /etc/apk/keys/bigbes@artifacts.srht.bigb.es.rsa.pub \ https://artifacts.srht.bigb.es/~bigbes/keys/apk.rsa.pub echo https://artifacts.srht.bigb.es/~bigbes/main/apk/v3.22 >> /etc/apk/repositories apk update && apk add dolt.sr.ht ``` The name of the key file is not free: apk looks a signature up by the name carried in the index's `.SIGN.RSA256.` entry, so it has to be `bigbes@artifacts.srht.bigb.es.rsa.pub` and nothing else. With the key in place no `--allow-untrusted` is needed — the daemon signs the index it builds. ## 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. ## coverage The profile the `test` task already wrote, POSTed to this instance's own cov.sr.ht. Dogfooding: the service that hosts the coverage of the sibling services now publishes this one's. It is its own task, and it comes **after** `publish` and `cache_save` for the reason every upload here does — a rejected report must not cost an apk that was built, signed and shipped. It comes **before** `bench` because the profile is already in hand while the benchmark run is minutes long, and a long run has no business standing between a finished profile and its upload. Three things in it are not decoration: - `test -s "$HOME/cover.out"` before anything else. A missing or empty profile is a `test` task that did not write one, and the service would answer that with a 400 about a body — a message about the request, when the fact is about the build. - The `~/.srht-token` gate. Without the file this build was handed no secrets; that is a manual submission, not a failure, and the profile is still this build's `cover.out` artifact. **With** the file the upload is fatal on purpose: a build that has the credential and cannot publish is a build that should say so. - `set +x` immediately before the `curl`. The task runs under `set -x`, and the `Authorization` header would otherwise be printed into a build log that is public. ## bench This service's own benchmarks, uploaded to this instance's own bench.sr.ht, `&visibility=public` so the repository the first POST creates is readable. ### What is measured, and why those There is no SQL engine behind a hosted database here: a bare NBS store has no working set, so every table is read whole and projected in process. That projection — not a query planner, and not the disk — is what a page costs, and it is what the benchmarks drive: - `BenchmarkBoardBuild` / `BenchmarkBoardBuildFiltered` / `BenchmarkDetailBuild` (`beads/bench_test.go`) run `beads.Build` over a synthetic tracker of 1200 issues, 1199 dependency edges in a binary tree, two labels per issue, a comment and an event each. That is the board a reader opens: the lane bucketing, the transitive dependency walk, the label join, the filter options and the per-lane sorts. The rows arrive through the package's own `BrowseSession` seam, already read, so what is timed is the projection and not how fast the builder's disk was that morning. 1200 is under `beads.Max` (2000) deliberately — over it the projection measures its truncation branch instead. - `BenchmarkMemoryBodyRender` / `…NoIndex` (`web/markdown_bench_test.go`) render one memory body through `memoryLinks.Body`: goldmark parses it, `[[slug]]` resolves against the cross-database index, and every text node is scanned for id-shaped tokens. The index is built by the real `beads.PrefixesAcross` over a fake session, outside the timed loop, because what the render costs depends on how many prefixes and slugs the index holds. The `NoIndex` variant is the same body with no index at all — the difference between the two is what resolution costs, which is the number worth watching. Every one of them asserts its own result inside the loop (the board carried 1200 cards, the wikilink resolved). A projection that silently produced nothing would otherwise be the fastest entry in the file. None of them needs Postgres and none needs the `dolt` CLI, which is why this task can sit at the end of the pipeline without a DSN guard: the `browse` tests' fixture needs the CLI and skips without it, but nothing benchmarked here goes through that fixture. ### The two greps `go test -bench` that matches nothing prints `ok` and exits `0`, and a file with no benchmark lines in it is *valid* benchfmt. A renamed or deleted benchmark would therefore upload an empty run and report success. So the names are checked against the file before the upload: ```sh grep -q '^BenchmarkBoardBuild' "$HOME/bench.txt" grep -q '^BenchmarkMemoryBodyRender' "$HOME/bench.txt" ``` One per benchmarked package, so losing either package's benchmarks is loud. `make -s bench > "$HOME/bench.txt"` and then `cat`, and deliberately **not** `| tee`: a pipeline's exit status is the last command's, so `tee` would let a failing benchmark run pass. `-s` keeps make from echoing the recipe into a file the parser will read. `BENCH_COUNT` is 10. bench.sr.ht marks a point measured under six repetitions "low n" — a point's confidence interval only becomes finite at six — so a run uploaded with fewer is a run nobody can read a regression off. A checkout that only wants to know the benchmarks still run says `make bench BENCH_COUNT=1`. A builder VM this small measures a **shape**, not a number: the absolute ns/op is worth nothing next to a laptop's, and the point of uploading it is that it is measured the same way every time. ## The two requests Both are a POST with the token as a Bearer header and the file as `--data-binary`, and both carry the same four query parameters: | parameter | value | why | |---|---|---| | `commit` | `git rev-parse HEAD` | what the numbers are about | | `ref` | `$GIT_REF` with both prefixes stripped | see below | | `key` | `$JOB_ID` | the idempotency key: a resubmitted job replaces, not duplicates | | `job_url` | `$JOB_URL` | the build a report links back to | `ref="${GIT_REF#refs/heads/}"; ref="${ref#refs/tags/}"` strips **both** prefixes because this pipeline builds tags too (`allow-refs` carries `refs/tags/v*`), and a tag build would otherwise report `ref=refs/tags/v0.2.0`. `GIT_REF` is absent altogether on a manually submitted build, which is fine: the parameter is optional. The coverage POST sends **no `Content-Type`** — cov.sr.ht sniffs the body, and a wrong declared type is worse than none. Both use `curl -sS --fail-with-body`, which prints the service's JSON error *and* still exits non-zero; plain `--fail` would swallow the only sentence saying what was wrong. ## What is not here - **No matrix.** One architecture, one image. - **No `gofmt` gate.** See [test](#test). - **No `integration` or `spike` build tag.** See [`remoteapi/integration_test.go` does not want Docker](#remoteapiintegration_testgo-does-not-want-docker).