diff --git a/Makefile b/Makefile index 67c7260..5adfcec 100644 --- a/Makefile +++ b/Makefile @@ -6,7 +6,7 @@ BINARY := sfdupes VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) LDFLAGS := -X main.Version=$(VERSION) -.PHONY: sfdupes build bootstrap setup test lint fmt fmt-check check docker hooks clean +.PHONY: sfdupes build bootstrap setup test test-race lint fmt fmt-check check docker hooks clean # Standard targets are thin shims; the implementations live in script/ # per the scripts-to-rule-them-all pattern. @@ -27,6 +27,9 @@ setup: test: @script/test +test-race: + @script/test-race + lint: @script/lint diff --git a/README.md b/README.md index b4078a2..41afbc8 100644 --- a/README.md +++ b/README.md @@ -742,14 +742,23 @@ entrypoints are: installed: they run in Docker (see `script/lint` and `script/fmt`) and never from a host install, so there is no host copy to drift from the pin. A missing `docker` is warned about rather than installed or treated as fatal — - everything except linting and formatting works without it. Ends with - `go mod download`. + everything except linting, formatting and `make test-race` works without it. + Ends with `go mod download`. - `script/setup` — make a fresh clone ready for development: runs `script/bootstrap`, then `script/install-precommit`. - `script/projectname` — print this project's name (`sfdupes`). Scripts that need the name call it, so they stay identical across repositories. - `script/test` — run the test suite with a 30-second timeout and coverage enabled, rerunning verbosely on failure so the logs show which test failed. +- `script/test-race` — run the test suite under the race detector with a + 60-second timeout. The detector needs cgo and a C compiler, which the build + never uses, so the tests run in a digest-pinned Debian `golang` image that has + `gcc`, with the checkout mounted read-only; the container is removed when it + exits. They run as the calling user, or as `nobody` when that is root, because + several tests make a file unreadable and root reads it anyway; only then must + the checkout be readable by other users. Not part of `script/check`. Every run + starts with empty caches, so it needs the network and takes minutes, and the + mount needs a local docker daemon. - `script/lint` — run the linter. It builds `Dockerfile.lint`, which copies the repository into the digest-pinned `golangci/golangci-lint` image and runs `golangci-lint config verify` and `golangci-lint run` as build steps, so a @@ -840,6 +849,8 @@ compile recipe: - `make setup` — prepare a fresh clone: `bootstrap` plus the pre-commit hook. - `make test` — run the test suite (30-second timeout; reruns with `-v` on failure). +- `make test-race` — run the test suite under the race detector, in Docker (see + `script/test-race`); requires `docker`. Not part of `make check`. - `make lint` — run `golangci-lint` with the repo config, in Docker (see `script/lint`); requires `docker`. - `make fmt` / `make fmt-check` — format the Go sources and the Markdown / diff --git a/TODO.md b/TODO.md index a962fe4..6b7118a 100644 --- a/TODO.md +++ b/TODO.md @@ -28,6 +28,10 @@ # Completed Steps +- `make test-race` runs the test suite under the race detector in a cgo-enabled + container, outside `make check` (2026-10-04, + https://git.eeqj.de/sneak/sfdupes/issues/18) + - a bare `docker build .` fails with a message naming `script/cibuild` and `script/docker` instead of serving the gates from cache (2026-10-04, https://git.eeqj.de/sneak/sfdupes/issues/39) @@ -497,5 +501,6 @@ Accepted divergences (no action): - flat single-package layout with `.go` files in the repo root — fine for a small single-binary tool per the Go styleguide; the tracker audit agrees -- `go test` runs without `-race` — the repo mandates `CGO_ENABLED=0` (pure-Go - builds) and the race detector requires cgo +- `make test` runs without `-race` — the repo mandates `CGO_ENABLED=0` (pure-Go + builds) and the race detector requires cgo, so the detector runs in a separate + cgo-enabled container, `make test-race`, which is not part of `make check` diff --git a/script/bootstrap b/script/bootstrap index 380191d..224e8dd 100755 --- a/script/bootstrap +++ b/script/bootstrap @@ -7,8 +7,8 @@ # installed: golangci-lint (script/lint) and prettier (script/fmt, # script/fmt-check) run via docker only, pinned by hash, so their only # prerequisite is a working docker — which is warned about, not -# installed, because everything except linting and formatting works -# without it. +# installed, because everything except linting, formatting and +# make test-race works without it. set -eu ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" @@ -75,13 +75,14 @@ main() { # Linting and Markdown formatting run via docker only, so docker is # their prerequisite rather than something bootstrap installs. Warn, - # do not fail: everything except `make lint`, `make fmt` and - # `make fmt-check` — and, through them, `make check`, `make docker` - # and the pre-commit hook — works without it. + # do not fail: everything except `make lint`, `make fmt`, + # `make fmt-check` and `make test-race` — and, through them, + # `make check`, `make docker` and the pre-commit hook — works + # without it. if missing docker; then echo "bootstrap: WARNING: docker not found; make lint, make fmt," >&2 - echo "bootstrap: make fmt-check, make check and make docker" >&2 - echo "bootstrap: require it." >&2 + echo "bootstrap: make fmt-check, make check, make docker and" >&2 + echo "bootstrap: make test-race require it." >&2 fi go mod download diff --git a/script/test-race b/script/test-race new file mode 100755 index 0000000..8e85862 --- /dev/null +++ b/script/test-race @@ -0,0 +1,40 @@ +#!/bin/sh +# script/test-race: run the test suite under the race detector. Not part +# of script/check. +# +# The race detector needs cgo and a C compiler, which the host build +# never uses, so the tests run in a golang image that has gcc. The +# checkout is mounted read-only, so the docker daemon must be local. The +# container starts with empty caches every time: each run downloads the +# dependencies and compiles them with the detector, which needs the +# network and takes minutes. +# +# The tests run as the calling user, never as root: several of them make +# a file unreadable and expect reading it to fail, and root reads it +# anyway. When the caller is root they run as nobody, and then the +# checkout must be readable by other users. Neither user has a home +# directory in the image, so HOME is /tmp, where Go puts its build cache. +set -eu + +ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" + +# golang:1.25-trixie, 2026-10-04. Debian rather than the Alpine image the +# Dockerfile builds with, because this one includes gcc. +IMAGE="golang@sha256:2c4c60ef415fbfa5e90300722293bef36c5e63fae17570ce18f580af933dbd73" + +main() { + user="$(id -u):$(id -g)" + if [ "$(id -u)" -eq 0 ]; then + user=65534:65534 + fi + docker run --rm \ + --user "$user" \ + --env HOME=/tmp \ + --env CGO_ENABLED=1 \ + --volume "$ROOT:/src:ro" \ + --workdir /src \ + "$IMAGE" \ + go test -race -timeout 60s ./... +} + +main "$@"