diff --git a/Dockerfile b/Dockerfile index 424ee87..bb0a459 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,42 +1,53 @@ -# Lint stage -# Same image as Dockerfile.lint: change both pins together. -# golangci/golangci-lint:v2.12.2-alpine, 2026-08-07 -FROM golangci/golangci-lint:v2.12.2-alpine@sha256:91b27804074a0bacea298707f016911e60cf0cdbc6c7bf5ccacb5f0606d18d60 AS lint +# Lint phase. script/lint builds it alone. The linter is run directly: +# `make lint` and script/lint are themselves a docker build. +# golangci/golangci-lint:v2.12.2, 2026-10-04 +FROM golangci/golangci-lint@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS lint + +# The linter compiles every package, and govips needs the libvips +# headers for that. This image is Debian and has no apk, so they come +# from apt-get rather than script/bootstrap. +RUN apt-get update \ + && apt-get install -y --no-install-recommends libvips-dev \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN golangci-lint run --config .golangci.yml ./... + +# Test phase. script/test builds it alone. +# golang:1.25.4-alpine, 2026-02-25 +FROM golang:1.25.4-alpine@sha256:d3f0cf7723f3429e3f9ed846243970b20a2de7bae6a5b66fc5914e228d831bbb AS test WORKDIR /src -# script/bootstrap installs the build dependencies and downloads the Go -# modules. Only script/, go.mod and go.sum are copied first, so this -# layer is reused until one of them changes. +# script/bootstrap installs the build dependencies (a C compiler and the +# libvips and libheif headers) and downloads the Go modules. COPY script/ ./script/ COPY go.mod go.sum ./ RUN script/bootstrap -# Copy source code COPY . . -# Tells script/lint it is inside a container, so it runs the linter. -ENV container=docker +# Without -v first; on a failure, again with -v for the details, and +# the step fails even if the second run passes. +RUN go test -count=1 -timeout 90s -race -cover ./... || \ + { echo "--- Rerunning with -v for details ---"; \ + go test -count=1 -timeout 90s -race -v ./...; exit 1; } -# Run formatting check and linter. script/cibuild and script/docker pass -# a new CHECK_EPOCH on every run, and each check step names it in its -# command, so a new value reruns the step instead of reusing a cached -# success that checked nothing. A plain `docker build .` leaves it empty -# and reuses the check steps only for an identical build context. -ARG CHECK_EPOCH -RUN echo "check epoch: ${CHECK_EPOCH}" && make fmt-check -RUN echo "check epoch: ${CHECK_EPOCH}" && make lint - -# Build stage +# Build stage. Nothing is wanted from the two phases above: these copies +# make BuildKit build them first, so this stage runs only when lint and +# test passed. # golang:1.25.4-alpine, 2026-02-25 FROM golang:1.25.4-alpine@sha256:d3f0cf7723f3429e3f9ed846243970b20a2de7bae6a5b66fc5914e228d831bbb AS builder -# Depend on lint stage passing COPY --from=lint /src/go.sum /dev/null +COPY --from=test /src/go.sum /dev/null WORKDIR /src -# Build dependencies and Go modules, as in the lint stage +# Build dependencies and Go modules, as in the test phase COPY script/ ./script/ COPY go.mod go.sum ./ RUN script/bootstrap @@ -44,12 +55,8 @@ RUN script/bootstrap # Copy source code COPY . . -# Run tests; a new CHECK_EPOCH reruns them, as in the lint stage. -ARG CHECK_EPOCH -RUN echo "check epoch: ${CHECK_EPOCH}" && make test - # VERSION is declared here, not earlier: a new value reruns only the -# build, not script/bootstrap or the tests. Given none, the version is +# build, not script/bootstrap. Given none, the version is # `git describe --tags --always` of the .git in the build context (git # comes from script/bootstrap): the tag on a tagged commit, tag-N-gHASH # after one, the short commit when no tag is reachable. A context that @@ -68,7 +75,8 @@ RUN version="${VERSION:-$(git describe --tags --always)}"; \ -ldflags "-s -w -X main.Version=${version}" \ -o /pixad ./cmd/pixad -# Runtime stage +# Runtime stage, and the last one: a plain `docker build .` builds this +# stage and what it depends on, and nothing else. # alpine:3.21, 2026-02-25 FROM alpine:3.21@sha256:c3f8e73fdb79deaebaa2037150150191b9dcbfba68b4a46d70103204c53f4709 diff --git a/Dockerfile.lint b/Dockerfile.lint deleted file mode 100644 index 41e350b..0000000 --- a/Dockerfile.lint +++ /dev/null @@ -1,34 +0,0 @@ -# Dockerfile.lint: the container script/lint builds to run golangci-lint, -# which is never installed on the host. Pinned to the same image as the -# Dockerfile lint stage: change both pins together, or the two run -# different linter versions. -# -# golangci/golangci-lint:v2.12.2-alpine, 2026-08-07 -FROM golangci/golangci-lint:v2.12.2-alpine@sha256:91b27804074a0bacea298707f016911e60cf0cdbc6c7bf5ccacb5f0606d18d60 - -WORKDIR /src - -# pixa is CGO/libvips: the type-aware linters compile every package, so -# this image needs the same C libraries the build does. script/bootstrap -# installs them and downloads the Go modules. Only script/, go.mod and -# go.sum are copied first; they settle this layer's result, so it may -# safely be reused between runs. -COPY script/ ./script/ -COPY go.mod go.sum ./ -RUN script/bootstrap - -COPY . . - -# Tells script/lint it is inside a container, so it runs the linter. -ENV container=docker - -# script/lint passes a different CACHEBUST on every run, and BuildKit -# keys every RUN after this ARG on its value, so the lint step always -# runs instead of returning a cached success that linted nothing. -# -# Go's and golangci-lint's caches (/root/.cache, hundreds of MB) go on a -# tmpfs that is discarded after the step. Written into the layer, they -# would pile up as build cache on every run, since no later run, with -# its new CACHEBUST, can reuse that layer. -ARG CACHEBUST -RUN --mount=type=tmpfs,target=/root/.cache script/lint diff --git a/Makefile b/Makefile index 708d5e5..8b1de2a 100644 --- a/Makefile +++ b/Makefile @@ -32,11 +32,11 @@ fmt-check: fmt: @script/fmt -# Run linter +# Run linter (the lint phase of the Dockerfile) lint: @script/lint -# Run tests (30-second timeout) +# Run tests (the test phase of the Dockerfile) test: @script/test @@ -59,20 +59,20 @@ docker: docker-smoke: @script/docker-smoke -# Build Docker image tagged pixad:$(VERSION) and pixad:latest +# Build Docker image as `make docker` does, and also tag it pixa:$(VERSION) docker-versioned: - docker build --build-arg VERSION=$(VERSION) -t pixad:$(VERSION) -t pixad:latest . + @script/docker + docker tag pixa pixa:$(VERSION) -# Run tests in Docker (needed for CGO/libvips) +# Run tests in Docker, as `make test` does docker-test: - docker build --target builder --build-arg VERSION=$(VERSION) -t pixad-builder . - docker run --rm pixad-builder sh -c "CGO_ENABLED=1 GOTOOLCHAIN=auto go test -v ./..." + @script/test # Run local dev server in Docker devserver: docker-versioned devserver-stop docker run -d --name pixad-dev -p 8080:8080 \ -v $(CURDIR)/config.dev.yml:/etc/pixa/config.yml:ro \ - pixad:latest + pixa:latest @echo "pixad running at http://localhost:8080" # Stop dev server diff --git a/README.md b/README.md index 8597c53..d73de7a 100644 --- a/README.md +++ b/README.md @@ -568,22 +568,31 @@ them. We provide: - `script/setup` — make a fresh clone ready for development (bootstrap, then install-precommit) - `script/projectname` — output the project name ("pixa") -- `script/test` — run the test suite -- `script/lint` — run golangci-lint, always in a container (builds - `Dockerfile.lint` when run outside one) +- `script/test` — run the test suite: build the `test` phase of the + `Dockerfile`, tagged `pixa-test` +- `script/lint` — run golangci-lint: build the `lint` phase of the `Dockerfile`, + tagged `pixa-lint`; the linter never runs on the host - `script/fmt` — format all code (writes) -- `script/fmt-check` — check formatting (read-only) +- `script/fmt-check` — check formatting (read-only), on the host - `script/check` — run test, lint, and fmt-check -- `script/docker` — build the Docker image tagged via `script/projectname` +- `script/docker` — build the Docker image tagged via `script/projectname`, with + the version from `git describe`; the image's build stage depends on the `lint` + and `test` phases, so this runs them too - `script/docker-smoke` — build the image, start it, wait for it to be healthy -- `script/cibuild` — CI entrypoint: `docker build .` with a new - `CHECK_EPOCH` on every run, so the Dockerfile's checks run instead of - coming from the build cache, and a green run implies a green repo +- `script/cibuild` — CI entrypoint: run `script/bootstrap` (which installs Go + and the libvips libraries on the host), then `script/check`, then build the + image as `script/docker` does - `script/precommit` — pre-commit checks (`go mod tidy` guard, then `script/check`) - `script/install-precommit` — install the git pre-commit hook that runs `script/precommit` +Every `docker build` in these scripts passes `--no-cache`, so the lint and test +phases run on every build instead of coming from the build cache. +`script/check`, `script/cibuild`, `script/docker`, `script/lint`, `script/test`, +`script/setup` and `script/install-precommit` are the standard copies from +`sneak/prompts`, kept identical to them. + ## TODO See [TODO.md](TODO.md) for the full prioritized task list. diff --git a/TODO.md b/TODO.md index fefbc63..42a36d4 100644 --- a/TODO.md +++ b/TODO.md @@ -31,6 +31,21 @@ P2: security: per-IP rate limiting on the image routes # Completed Steps +- 2026-10-04 lint and tests run as the `lint` and `test` phases of the + `Dockerfile`, built with `--no-cache` (closes #202): `script/check`, + `script/cibuild`, `script/docker`, `script/lint`, `script/test`, + `script/setup` and `script/install-precommit` are now the copies from + `sneak/prompts` `main`, unchanged. The `lint` phase runs golangci-lint from + the image `REPO_POLICIES.md` names, with `libvips-dev` from `apt-get`; the + `test` phase runs the tests with a 90-second timeout; the build stage depends + on both. `Dockerfile.lint` and the `CHECK_EPOCH` build argument are gone, the + formatting check runs on the host, and `make docker-versioned` and + `make docker-test` call the scripts. `script/bootstrap`, `script/fmt`, + `script/fmt-check`, `script/precommit` and `script/projectname` stay pixa's + own: Go and libvips, `gofmt`, the `go mod tidy` guard, the name. The stage + that compiles still takes the version from `git describe` when no `VERSION` is + given, per https://git.eeqj.de/sneak/pixa/issues/166, so the copied scripts' + comment that `.dockerignore` leaves out `.git` does not hold for pixa. - 2026-10-04 local config files stay out of the Docker build context (closes #211): `.dockerignore` now leaves out `config.yaml` and `config.dev.yml` in every directory and in any letter case, the local config files `.gitignore` diff --git a/script/bootstrap b/script/bootstrap index 54c04ea..d163746 100755 --- a/script/bootstrap +++ b/script/bootstrap @@ -4,11 +4,11 @@ # installed tools are skipped. Base tooling comes from nix, apt, brew, # or apk (detected in that order); assumes NOTHING is present (not git, # make, or go). The linter is never installed on the host: golangci-lint -# runs only inside a container, Dockerfile.lint or the Dockerfile lint -# stage (see script/lint). A C compiler and the CGO image libraries -# (pkg-config, vips, libheif) are installed for the govips bindings. -# Both Dockerfiles run this script too, so their build dependencies are -# the ones listed here. +# runs only in the lint phase of the Dockerfile (see script/lint). A C +# compiler and the CGO image libraries (pkg-config, vips, libheif) are +# installed for the govips bindings. The Dockerfile's test phase and +# build stage run this script too, so their build dependencies are the +# ones listed here. set -eu ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" diff --git a/script/check b/script/check index cc046f7..92875f7 100755 --- a/script/check +++ b/script/check @@ -1,7 +1,8 @@ #!/bin/sh # script/check: run all checks (test, lint, fmt-check). Our own -# extension to scripts-to-rule-them-all. Must not modify any files. -# Generic: usually needs no adaptation. +# extension to scripts-to-rule-them-all. test and lint are Docker +# phases; fmt-check is native, because a formatter writes the working +# tree. Must not modify any files. set -eu SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" diff --git a/script/cibuild b/script/cibuild index 948134c..688299f 100755 --- a/script/cibuild +++ b/script/cibuild @@ -1,18 +1,29 @@ #!/bin/sh -# script/cibuild: run the CI build. The Dockerfile runs the checks -# (make fmt-check, lint, test) as build steps. This script passes a new -# CHECK_EPOCH on every run, so Docker runs those steps instead of -# reusing cached results: a successful run means the checks ran and -# passed on this tree. Generic: needs no adaptation. The Gitea workflow -# runs this on push. +# script/cibuild: run the CI build. It bootstraps first: a CI runner +# checks out and runs this and nothing else, and script/fmt-check runs +# the formatter on the host, which a pristine checkout cannot do. +# --no-cache for the same reason as script/docker: the gate phases the +# final stage depends on are RUN steps, and a cached one is a check that +# did not run. set -eu -ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" +ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)" main() { cd "$ROOT" - epoch="$(date +%s)$$" - docker build --build-arg CHECK_EPOCH="$epoch" . + "$SCRIPT_DIR/bootstrap" + "$SCRIPT_DIR/check" + # Own line: a failing command substitution inside an argument does + # not trip `set -e`, so the inline form degrades silently to an + # empty constant. VERSION is computed here because .dockerignore + # excludes .git, so `git describe` in a build stage yields an empty + # version without failing. + version="$(git describe --tags --always --dirty 2>/dev/null || true)" + [ -n "$version" ] || version="unknown" + docker build --no-cache \ + --build-arg VERSION="$version" \ + -t "$("$SCRIPT_DIR/projectname")" . } main "$@" diff --git a/script/docker b/script/docker index b9907d8..c4688e8 100755 --- a/script/docker +++ b/script/docker @@ -1,9 +1,8 @@ #!/bin/sh # script/docker: build the Docker image tagged with the project name. -# Identical in all repos; the tag comes from script/projectname. Like -# script/cibuild, it passes a new CHECK_EPOCH, so the build runs the -# checks instead of reusing cached results. Generic: needs no -# adaptation. +# Identical in all repos; the tag comes from script/projectname. +# --no-cache because the gate phases the final stage depends on are RUN +# steps, and a cached one is a check that did not run. set -eu SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" @@ -11,8 +10,15 @@ ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)" main() { cd "$ROOT" - epoch="$(date +%s)$$" - docker build --build-arg CHECK_EPOCH="$epoch" \ + # Own line: a failing command substitution inside an argument does + # not trip `set -e`, so the inline form degrades silently to an + # empty constant. VERSION is computed here because .dockerignore + # excludes .git, so `git describe` in a build stage yields an empty + # version without failing. + version="$(git describe --tags --always --dirty 2>/dev/null || true)" + [ -n "$version" ] || version="unknown" + docker build --no-cache \ + --build-arg VERSION="$version" \ -t "$("$SCRIPT_DIR/projectname")" . } diff --git a/script/install-precommit b/script/install-precommit index 49da044..bef6406 100755 --- a/script/install-precommit +++ b/script/install-precommit @@ -1,13 +1,13 @@ #!/bin/sh # script/install-precommit: install the git pre-commit hook that runs # script/precommit. Our own extension to scripts-to-rule-them-all. -# Generic: needs no adaptation. set -eu ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" main() { cd "$ROOT" + hook=".git/hooks/pre-commit" printf '#!/bin/sh\nset -e\nscript/precommit\n' > .git/hooks/pre-commit chmod +x .git/hooks/pre-commit echo "pre-commit hook installed: runs script/precommit" diff --git a/script/lint b/script/lint index 96aa46d..2d8b075 100755 --- a/script/lint +++ b/script/lint @@ -1,37 +1,23 @@ #!/bin/sh -# script/lint: run golangci-lint over the whole tree. This is the only -# way the linter is run, everywhere; it is never installed on the host. +# script/lint: run the linter. Linting is a phase of the Dockerfile and +# this builds that phase alone; the linter is never installed or run on +# a developer host, where a shared result cache and a host-global lock +# make its answer untrustworthy. # -# Inside a container it runs the linter. Anywhere else it builds -# Dockerfile.lint, whose last step runs this script again inside that -# container. -# -# Dockerfile.lint and the Dockerfile lint stage set container=docker -# (the systemd convention for marking a container) to say where we are. -# /.dockerenv cannot: it is missing inside build steps, and present on -# hosts that are themselves containers. +# The phase is not the last stage in the file, so it is built only when +# --target names it. --no-cache because a cached lint layer is a lint +# that did not run. The tag makes each build replace the previous image +# instead of leaving a dangling one behind. set -eu -ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" +ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)" main() { cd "$ROOT" - if [ "${container:-}" = docker ]; then - # `golangci-lint config verify` is not run: it fetches its JSON - # schema over an unpinned live HTTPS call, which REPO_POLICIES.md - # forbids. - echo "Running linter..." - golangci-lint run --config .golangci.yml ./... - else - # A new CACHEBUST on every run means the lint step is never - # served from cache (see Dockerfile.lint). The cacheonly output - # leaves no image behind. - docker build \ - --progress=plain \ - --build-arg CACHEBUST="$(date +%s)-$$" \ - --output=type=cacheonly \ - -f Dockerfile.lint . - fi + docker build --no-cache \ + --target lint \ + -t "$("$SCRIPT_DIR/projectname")-lint" . } main "$@" diff --git a/script/setup b/script/setup index 53327ba..4cc5b6b 100755 --- a/script/setup +++ b/script/setup @@ -1,7 +1,6 @@ #!/bin/sh # script/setup: set up the repo for development after a fresh clone: -# installs dependencies (script/bootstrap) and the git pre-commit hook. -# Add any repo-specific initialization (db init, .env template) here. +# installs dependencies and the git pre-commit hook. set -eu SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" diff --git a/script/test b/script/test index c0bcc75..cd239f2 100755 --- a/script/test +++ b/script/test @@ -1,27 +1,19 @@ #!/bin/sh -# script/test: run the test suite. CGO dependencies (pkg-config, vips, -# libheif) come from nix-shell when not already available (e.g. inside -# a Docker build or an existing nix-shell). +# script/test: run the test suite. Testing is a phase of the Dockerfile +# and this builds that phase alone, on the same terms as script/lint: +# --target because a phase that is not the last stage is built only when +# named, --no-cache because a cached test layer is a test that did not +# run, and a tag so each build replaces the previous image. set -eu -ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" - -run_with_cgo_deps() { - if command -v pkg-config >/dev/null 2>&1; then - sh -c "$1" - else - nix-shell -p pkg-config vips libheif git --run "$1" - fi -} +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" +ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)" main() { cd "$ROOT" - echo "Running tests..." - # Run without -v first for clean output on success; on failure rerun - # with -v for full diagnostics, then exit non-zero (REPO_POLICIES.md - # conditional-verbose-rerun pattern). The first run already proved the - # tests broken, so the build fails even if the rerun happens to pass. - run_with_cgo_deps "CGO_ENABLED=1 go test -timeout 30s -race -cover ./... || { echo '--- Rerunning with -v for details ---'; CGO_ENABLED=1 go test -timeout 30s -race -v ./...; exit 1; }" + docker build --no-cache \ + --target test \ + -t "$("$SCRIPT_DIR/projectname")-test" . } main "$@"