diff --git a/.build.yml b/.build.yml new file mode 100644 index 0000000000000000000000000000000000000000..4b2b8da4f6fe7e7c1f11b0d045604a72c6c2df89 --- /dev/null +++ b/.build.yml @@ -0,0 +1,223 @@ +# builds.sr.ht manifest for sr-ht-ecore. One linear pipeline: restore the Go +# caches, refuse an unformatted or vet-dirty tree, test with a coverage profile, +# run the benchmarks, then upload both to the instance's own cov.sr.ht and +# bench.sr.ht. +# +# This repository is a LIBRARY — no cmd/, no daemon, no stylesheet, no apk — and +# seven services pin it by pseudo-version, which is exactly why it needs a +# pipeline of its own: a break here is discovered in whichever service next runs +# `go get -u`, at the moment that service is being released. Everything a +# sibling manifest carries for its package (scss, keygen, abuild, publish, +# publish_artifacts) has no meaning here and is absent rather than stubbed, and +# there is no postgres task because nothing in this module imports database/sql: +# no test opens a database, no test skips on a missing DSN. +# +# The reasoning lives in comments here rather than in a docs/ci.md, because this +# repository has no docs/ tree. Keep it under 16 KiB: builds.sr.ht stores the +# submitted manifest in a varchar(16384), and a manifest over that cannot be +# submitted at all — the failure is a branch with no CI, not a red build. +image: alpine/edge +packages: + - go + - git + # Every task below drives the Makefile. The sibling manifests never list this + # and their `make test` works, so the image evidently ships one — but abuild's + # 14 dependencies do not include it (checked on pkgs.alpinelinux.org), so what + # they rely on is the image's furniture rather than a package they asked for. + # A build that needs make asks for make. + - make + # For the cacher's install.sh and for the two uploads, and for nothing else. + - curl +secrets: + # S3 credentials for the cacher CI cache (Garage `docker-cache` bucket). + # Account-level secrets shared with the sibling services, so this repository + # holds nothing of its own. Absent, cacher_install turns the cache off and the + # build compiles cold instead of failing. + - 7dde4219-0783-4581-a67d-c94749de3600 # ~/.s3-cache-key-id + - 0e5b3530-6f19-4f30-9b73-9339dd382e46 # ~/.s3-cache-key-secret + # ~/.srht-token holds a tokens.sr.ht WORKING TOKEN, and it is one secret shared + # with the cov.sr.ht and bench.sr.ht pipelines rather than a per-repository + # one: the credential is minted once, for a person, and carries the grants of + # every service it is meant to reach. This build needs cov:upload and + # bench:upload from it; a token missing one fails that upload and no other. + # The grant was cover:upload before the service was renamed to cov.sr.ht, and + # grants are compared literally, so a token minted before the rename uploads + # no coverage here — re-mint it on tokens.sr.ht and replace this secret. + - c7968415-1a6d-4ca0-a188-150fb7f57b65 # ~/.srht-token +sources: + - https://git.srht.bigb.es/~bigbes/sr-ht-ecore +environment: + REPO: sr-ht-ecore + COVER_ORIGIN: https://cov.srht.bigb.es + COVER_REPO: "~bigbes/sr-ht-ecore" + BENCH_ORIGIN: https://bench.srht.bigb.es + BENCH_REPO: "~bigbes/sr-ht-ecore" +# Literal paths relative to $HOME — which is why the two tasks below write there +# and not into the checkout. They are not a fallback for the uploads: a build +# submitted without secrets still leaves both files downloadable, and a POST +# that failed leaves the body that was meant to be sent. +artifacts: + - cover.out + - bench.txt +submitter: + git.sr.ht: + allow-refs: + - refs/heads/master + # A library is consumed by tag as well as by pseudo-version, so a pushed + # tag has to be tested too. Without this line it starts no build at all. + - "refs/tags/v*" +tasks: + # S3-backed CI cache helper. install.sh's PATH export goes to ~/.buildenv, + # which only the NEXT task sources — hence two tasks and not one. + # + # The guard is not in the donors' copies of this task, and it is here because + # this repository is a library: a contributor's manually submitted build asks + # for no secrets, and cacher init would then die on an unreadable key file + # before a single test had run. Without the cache the build is slower and + # exactly as truthful. + - cacher_install: | + if [ ! -r ~/.s3-cache-key-id ] || [ ! -r ~/.s3-cache-key-secret ]; then + echo "no S3 cache credentials: this build compiles from cold" + echo "export NO_CACHE=1" >> ~/.buildenv + exit 0 + fi + curl -sSL https://bigbes.pages.srht.bigb.es/ci-cacher/install.sh | sh + - cacher_init: | + [ -z "$NO_CACHE" ] || { echo "cache disabled: nothing to init"; exit 0; } + cacher init \ + --endpoint https://s3.bigb.es \ + --region garage \ + --bucket docker-cache \ + --prefix sr-ht-ecore/deps \ + --key-file ~/.s3-cache-key-id \ + --secret-file ~/.s3-cache-key-secret + - cache_restore: | + cd "$REPO" + if [ -z "$NO_CACHE" ]; then + # Module and build caches keyed by go.sum; --optional makes a miss a + # cold build, not an error. + KEY_MOD=$(cacher key "gomod/{hash}.tar.zst" --hash-from go.sum) + KEY_GOC=$(cacher key "gocache/{hash}.tar.zst" --hash-from go.sum) + echo "export KEY_MOD=$KEY_MOD KEY_GOC=$KEY_GOC" >> ~/.buildenv + 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: it is the normal + # failure of a restore, not a freak one, and it surfaces later as + # compile errors inside a dependency that read like a code bug + # (cover.sr.ht's build #284). Do not soften this to `|| true`. + 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 + fi + # `go mod download`, NEVER `go mod download all`. The `all` pattern + # resolves the whole module graph including dependencies' test-only + # requirements and APPENDS their hashes to the TRACKED go.sum — silently, + # exit 0 — which leaves a modified file in the checkout. -mod=readonly + # does NOT prevent it: readonly governs the module requirements, not + # writes to go.sum. Without `all`, go.sum is untouched and what the cache + # holds is what this module builds and tests anyway. + go mod download + go mod verify + # And the proof that it did not: a print here would be a flag we trust, + # so the clean tree is asserted instead. The `all` spelling fails this + # line with a go.sum of a few hundred added hashes. + test -z "$(git status --porcelain)" || { git status --porcelain; exit 1; } + - lint: | + cd "$REPO" + # check-fmt and not fmt: `gofmt -l` prints the offending files and STILL + # EXITS 0, so a task that ran plain gofmt could not fail, and `make fmt` + # rewrites — a gate that edits the tree it is judging is not a gate. + make check-fmt + make vet + - test: | + cd "$REPO" + # -covermode=atomic (real hit counts, which is what cov.sr.ht reads) and + # $HOME, because that is where artifacts: looks. The Makefile owns the + # command; this task owns the destination. `make cover` also refuses an + # empty profile, which would otherwise upload a report covering nothing + # and call it a success. + make cover COVERPROFILE="$HOME/cover.out" + - bench: | + cd "$REPO" + # -s so make does not echo the recipe into the body; a redirect and a cat + # and NOT `| tee`, which would hand this task tee's exit status and let a + # failing benchmark pass. + # + # What lands in the file is benchfmt and nothing else because the Makefile + # filters it (see BENCH_FILTER there): a benchmark that provokes logging + # in the code under test writes those lines into this same stream, and one + # of the benchmarks in this tree currently produces hundreds of megabytes + # of them. The command that ran is echoed to stderr, so it is in this + # task's log and not in the body being uploaded. + make -s bench > "$HOME/bench.txt" + cat "$HOME/bench.txt" + # `go test -bench` that matches nothing prints `ok` and exits 0, and a + # file with no benchmark lines is still valid benchfmt — so a renamed or + # deleted benchmark would upload an empty run and report success. One + # name per benchmark file, so that losing any one file is caught: + grep -q '^BenchmarkValidate' "$HOME/bench.txt" # bearer + grep -q '^BenchmarkRequestLogger' "$HOME/bench.txt" # chimw + grep -q '^BenchmarkRequire' "$HOME/bench.txt" # csrf + grep -q '^BenchmarkParse' "$HOME/bench.txt" # grants + grep -q '^BenchmarkChain' "$HOME/bench.txt" # middleware + - cache_save: | + [ -z "$NO_CACHE" ] || { echo "cache disabled: nothing to save"; exit 0; } + # After the runs that warm it, and fatal on purpose. Without --force an + # upload skips a key that is already there, so no `cacher exists` guard is + # needed. + cacher dir upload "$KEY_MOD" ~/go/pkg/mod + cacher dir upload "$KEY_GOC" ~/.cache/go-build + - coverage: | + cd "$REPO" + # The gate is the honest answer to a build that was handed no secrets: the + # profile is made, it is this build's cover.out artifact, and it can be + # POSTed by hand. With the file present the upload is fatal on purpose. + if [ ! -r ~/.srht-token ]; then + echo "no ~/.srht-token: this build has no cov.sr.ht credentials" + echo "the profile is still available as this build's cover.out artifact" + exit 0 + fi + # GIT_REF is absent on a manually submitted build and ref is optional for + # the API; key is the idempotency key, so a resubmitted job replaces its + # own report instead of adding a second one. Both prefixes are stripped + # because this pipeline builds tags too, and a tag build would otherwise + # report ref=refs/tags/v0.1.0. + ref="${GIT_REF#refs/heads/}" + ref="${ref#refs/tags/}" + url="$COVER_ORIGIN/api/v1/repos/$COVER_REPO/reports" + url="$url?commit=$(git rev-parse HEAD)&ref=$ref&key=$JOB_ID&job_url=$JOB_URL" + echo "uploading cover.out to $url" + # Tracing off to the end of the task: the Authorization header must not + # reach the log. No Content-Type — the service sniffs the format, and a + # wrong one is a 400. --fail-with-body prints the JSON error AND still + # exits non-zero, which plain --fail does not. + set +x + curl -sS --fail-with-body -X POST \ + -H "Authorization: Bearer $(cat ~/.srht-token)" \ + --data-binary "@$HOME/cover.out" \ + "$url" + echo + - bench_upload: | + cd "$REPO" + if [ ! -r ~/.srht-token ]; then + echo "no ~/.srht-token: this build has no bench.sr.ht credentials" + echo "the benchmarks ran and are in the bench task's log" + echo "the file is this build's bench.txt artifact, and can be POSTed by hand" + exit 0 + fi + # visibility acts only on the POST that creates $BENCH_REPO; on every + # later run it is ignored. + ref="${GIT_REF#refs/heads/}" + ref="${ref#refs/tags/}" + url="$BENCH_ORIGIN/api/v1/repos/$BENCH_REPO/runs" + url="$url?commit=$(git rev-parse HEAD)&ref=$ref&key=$JOB_ID&job_url=$JOB_URL" + url="$url&visibility=public" + echo "uploading bench.txt to $url" + set +x + curl -sS --fail-with-body -X POST \ + -H "Authorization: Bearer $(cat ~/.srht-token)" \ + --data-binary "@$HOME/bench.txt" \ + "$url" + echo diff --git a/Makefile b/Makefile new file mode 100644 index 0000000000000000000000000000000000000000..3808128fac8010ba0925275c0a70a53fb2fa0d5e --- /dev/null +++ b/Makefile @@ -0,0 +1,182 @@ +# sr-ht-ecore — build scaffolding (bench.sr.ht / cov.sr.ht family style). +# +# This repository is a LIBRARY: no cmd/, no daemon, no stylesheet, no apk. There +# is nothing to install and nothing to package, so the targets a service's +# Makefile carries for those — css, keygen, install, check-version — have no +# meaning here and are absent rather than stubbed. +# +# What is left is what the seven services pinning this module actually need from +# it: that it compiles, that it is formatted and vets clean, that its tests pass, +# and that its coverage profile and its benchmarks come out in the shape +# .build.yml uploads to cov.srht.bigb.es and bench.srht.bigb.es. That is what the +# targets below are, and .build.yml calls them by name — the Go command lines +# live here, once, instead of being copied into the manifest. + +GO?=go +GOFMT?=gofmt + +# PKG is what to run and COUNT is `go test -count`, both variables so that +# `make test-race COUNT=10 PKG=./bearer/` needs no retyping of the flags that +# get forgotten when a command is retyped by hand. +PKG?=./... +COUNT?=1 + +# TEST_TIMEOUT is the per-package ceiling handed to `go test`, and -timeout is +# per test binary, so what it has to cover is the slowest package and not the +# suite. `make test` does not need it: 11 s wall for the whole tree on an M4 +# Pro, measured with go1.25 at -count=1, no package over 10 s. `make bench` +# is where it earns its keep — measured on that same machine at BENCH_COUNT=10, +# 6 min 06 s wall over 29 benchmark cases, of which grants took 96.4 s, bearer +# 78.5 s and csrf 73.0 s. A sixth of the toolchain's ten-minute default is +# comfortable on a laptop and not obviously comfortable on a builds.sr.ht VM, +# which is a slower machine running the same fixed number of iterations. +# Twenty minutes is the number the sibling services carry, for the same reason: +# what it prevents — `panic: test timed out` over three hundred lines of +# goroutine dump — reads like a hang in the code rather than like a budget. +TEST_TIMEOUT?=20m + +# BENCH_COUNT is `go test -count` for the `bench` target. Ten is bench.sr.ht's +# own number (its SPEC ch. 5): a confidence interval becomes finite at six +# repetitions and a comparison becomes significant at four, so anything under +# six uploads points the service can only mark "low n". It is a variable so a +# laptop can say `make bench BENCH_COUNT=1` when it only wants to know that the +# benchmarks still run. +BENCH_COUNT?=10 + +# Where `make cover` writes the profile. .build.yml overrides it to $HOME — +# that is where builds.sr.ht's artifacts: looks — and the default is the +# checkout, which is what a developer means by `make cover`. It lands untracked +# there; `make clean` is what removes it, and .gitignore does not list it. +COVERPROFILE?=cover.out + +# The sibling services default to `build`, and each of them says in the same +# breath that it was `help` while there was nothing to build. Here there is +# nothing to build — `build` is a compile check, not a product — so the default +# stays where theirs started, and a bare `make` spends no time proving something +# the author did not ask about. +.DEFAULT_GOAL := help + +help: + @echo "targets:" + @echo " help this list (the default target)" + @echo " build compile every package: go build \$$(PKG)" + @echo " test go test \$$(PKG) with an explicit -timeout" + @echo " test-race the same under -race and repeatable:" + @echo " make test-race COUNT=10 PKG=./bearer/" + @echo " cover test with -covermode=atomic into \$$(COVERPROFILE) and" + @echo " print the total; the profile .build.yml POSTs to cov.sr.ht" + @echo " (COVERPROFILE=$(COVERPROFILE))" + @echo " bench go test -bench=. \$$(PKG) as benchfmt on stdout, the format" + @echo " .build.yml POSTs to bench.sr.ht (BENCH_COUNT=$(BENCH_COUNT));" + @echo " everything that is not benchfmt is filtered out — see" + @echo " BENCH_FILTER, and 'make bench BENCH_FILTER=.' for the raw run" + @echo " vet go vet \$$(PKG)" + @echo " fmt rewrite this module's Go files with gofmt" + @echo " check-fmt the same as a refusal: fail if gofmt would rewrite anything" + @echo " (this is the one CI runs; fmt writes, gates must not)" + @echo " clean remove \$$(COVERPROFILE)" + +# There is no binary to link, so this is a compile check and nothing else. It is +# still worth a target: `go vet` compiles too, but a build failure reported by +# vet reads like a vet finding. +build: + $(GO) build $(PKG) + +test: + $(GO) test -timeout $(TEST_TIMEOUT) $(PKG) + +# The same suite under the race detector, repeatable. Half of what this library +# holds is middleware sitting on every request of every service — chimw's +# logger, middleware's chain, mcphttp's cache — so the run that matters for it +# is this one, not `test`. +test-race: + $(GO) test -race -count=$(COUNT) -timeout $(TEST_TIMEOUT) $(PKG) + +# -covermode=atomic and not the default `set`: `set` records whether a statement +# ran, atomic records how often, and cov.sr.ht reads hit counts. The profile is +# the artifact; the printed total is a convenience. +# +# The `test -s` in the middle is not decoration. A profile that came out empty +# is still a valid file, and uploading it would report success over a report +# that covers nothing — the same failure the .build.yml bench task greps +# against. +cover: + $(GO) test -covermode=atomic -coverprofile=$(COVERPROFILE) -timeout $(TEST_TIMEOUT) $(PKG) + @test -s $(COVERPROFILE) || { echo "$(COVERPROFILE) is empty" >&2; exit 1; } + $(GO) tool cover -func=$(COVERPROFILE) | tail -1 + +# The benchmarks in the form bench.sr.ht ingests: benchfmt on stdout, which is +# what `go test -bench` writes. -run='^$$' keeps the tests out of it — their +# output is noise the parser skips and their runtime is time added to a run +# whose point is the benchmarks. -benchmem because B/op and allocs/op are half +# of what a middleware benchmark means and they cost nothing to collect. +# +# BENCH_FILTER is the part that is not boilerplate, and it is here because a +# benchmark's own stdout is not only the benchmark. `go test` merges the test +# binary's stderr into its stdout, so anything the code under test logs during a +# measured loop lands in the middle of the benchfmt document. Measured on this +# tree at BENCH_COUNT=10: bearer's invalid-token case makes sr-ht-core's +# auth.DecodeBearerToken call log.Printf once per iteration, and an unfiltered +# run was 901 MB and 9.5 million lines deep — around the 290 result lines a full +# run has — when it was stopped, a third of the way through. Filtered, the same +# run is 340 lines and 31 KB. benchfmt ignores lines it cannot parse, so the +# unfiltered file would upload "successfully": a POST of a gigabyte of noise, +# whatever bench.sr.ht's byte budget makes of it. +# +# So the target emits the benchfmt grammar (its configuration lines and its +# result lines) plus the words that say a run failed, and nothing else. The +# filter is a variable: `make bench BENCH_FILTER=.` is the same run with every +# line of it. It is a defence and not a cure — the cure is for a benchmark that +# provokes logging to silence the logger first, the way middleware's discardLog +# and chimw's io.Discard handler already do. +# +# The exit status travels in a file because the grep is a pipe: taking the +# pipeline's status would report grep's opinion of the output instead of whether +# the benchmarks ran, which is the failure mode that makes a red suite read +# green. +BENCH_FILTER?=^(goos|goarch|pkg|cpu): |^Benchmark|^(PASS|FAIL|ok|--- |panic: ) + +bench: + @echo "+ $(GO) test -run='^$$' -bench=. -benchmem -count=$(BENCH_COUNT) -timeout $(TEST_TIMEOUT) $(PKG)" >&2 + @st=$$(mktemp); \ + { $(GO) test -run='^$$' -bench=. -benchmem -count=$(BENCH_COUNT) \ + -timeout $(TEST_TIMEOUT) $(PKG) 2>&1; echo $$? > "$$st"; } \ + | grep -E '$(BENCH_FILTER)' || true; \ + rc=$$(cat "$$st"); rm -f "$$st"; \ + test -n "$$rc" || rc=1; \ + exit "$$rc" + +vet: + $(GO) vet $(PKG) + +# GOFMT_DIRS is `go list`'s answer and not a literal `.`, and that is the whole +# point of the two targets below. The repository convention here is to create +# git worktrees under .worktrees/, so a plain `gofmt -l .` at the root +# would walk into another checkout of this same module and report — or, for +# `fmt`, rewrite — files that are not in this working tree at all. A worktree +# carries its own go.mod, so it is a different module to `go list`, and ./... +# never leaves this one. +GOFMT_DIRS=$$($(GO) list -f '{{.Dir}}' $(PKG)) + +fmt: + $(GOFMT) -l -w $(GOFMT_DIRS) + +# `gofmt -l` prints the offending files and STILL EXITS 0, so it passes any && +# chain and any CI task that only looks at the status. This target is that check +# written so a failure stops the build, and it is the one .build.yml calls; fmt +# above rewrites and must never be what a gate runs. +check-fmt: + @out=$$($(GOFMT) -l $(GOFMT_DIRS)); \ + if [ -n "$$out" ]; then \ + echo "gofmt would rewrite:" >&2; \ + echo "$$out" >&2; \ + exit 1; \ + fi; \ + echo "gofmt: clean" + +# Only the profile: `bench` writes to stdout and creates no file of its own, so +# there is nothing else here to remove. +clean: + rm -f $(COVERPROFILE) + +.PHONY: help build test test-race cover bench vet fmt check-fmt clean