From 2d570fba948c56d9f3ec373f1e18e53d5970da51 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Sun, 18 Oct 2026 23:23:30 +0000 Subject: [PATCH] Re-vendor the canonical files from sneak/prompts at 0653579 (closes #221) REPO_POLICIES.md, script/cibuild, script/docker, script/lint and script/test are their copies at that commit. In every Dockerfile stage the system package install and the Go module download now come before ARG CACHEBUST, so check builds keep those layers until the base image, the install line, go.mod or go.sum change. golangci-lint run also mounts golangci-lint's cache. The test phase and the build stage install their packages with apt-get and apk directly, as REPO_POLICIES.md now has them do, instead of through script/bootstrap --cgo. The test phase installs no recommended packages and names libheif's AVIF encoder, libheif-plugin-aomenc, which the tests use. Model: opus-5-5 --- Dockerfile | 53 ++++++++++++++---------- README.md | 35 +++++++++------- REPO_POLICIES.md | 103 +++++++++++++++++++++++++++++++---------------- TODO.md | 4 ++ script/bootstrap | 5 +-- script/cibuild | 9 +++-- script/docker | 9 +++-- script/lint | 12 +++--- script/test | 11 ++--- 9 files changed, 148 insertions(+), 93 deletions(-) diff --git a/Dockerfile b/Dockerfile index b2d7c80..9cc15d5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,12 +1,16 @@ -# Every stage declares ARG CACHEBUST first: script/ passes a new value -# on every build, which reruns every RUN step after it. The steps that -# compile mount Go's build cache, which survives from build to build. +# In every stage ARG CACHEBUST comes right after the steps that install +# system packages and download Go's modules, or right after FROM in a +# stage with neither, and every step that lints, tests or compiles +# comes after it. script/ passes a new value on every build, which +# reruns every RUN step after it. Docker keeps the layers above it +# until the base image, their own lines or go.mod and go.sum change. +# The steps that compile mount Go's build cache, and golangci-lint run +# also mounts golangci-lint's; both survive from build to build. # 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.14.0, 2026-10-15 FROM golangci/golangci-lint@sha256:ad862ba6b3798cbe0fd9fd7408d498fd74fbd2623a92406b2fd3898faf0bf98f AS lint -ARG CACHEBUST # The linter compiles every package, and govips needs the libvips # headers for that. REPO_POLICIES.md has the lint phase install them @@ -18,25 +22,29 @@ RUN apt-get update \ WORKDIR /src COPY go.mod go.sum ./ RUN go mod download +ARG CACHEBUST COPY . . RUN --mount=type=cache,target=/root/.cache/go-build \ + --mount=type=cache,target=/root/.cache/golangci-lint \ golangci-lint run --config .golangci.yml ./... # Test phase. script/test builds it alone. -race needs cgo and so a C # compiler, which the Debian Go image ships and the alpine one does not. # golang:1.25.4-trixie, 2026-10-15 FROM golang@sha256:a02d35efc036053fdf0da8c15919276bf777a80cbfda6a35c5e9f087e652adfc AS test -ARG CACHEBUST + +# govips compiles against the libvips and libheif headers, and the tests +# need libvips' JPEG XL support; libvips-dev brings all three. The tests +# also encode AVIF, which needs libheif's AVIF encoder, +# libheif-plugin-aomenc: Debian only recommends it, so it is named here. +RUN apt-get update \ + && apt-get install -y --no-install-recommends libvips-dev libheif-plugin-aomenc \ + && rm -rf /var/lib/apt/lists/* WORKDIR /src - -# script/bootstrap --cgo installs the build dependencies (the libvips -# and libheif headers, and libvips' JPEG XL support, which the tests -# need) and downloads the Go modules. -COPY script/ ./script/ COPY go.mod go.sum ./ -RUN script/bootstrap --cgo - +RUN go mod download +ARG CACHEBUST COPY . . # Without -v first; on a failure, again with -v for the details, and @@ -52,22 +60,23 @@ RUN --mount=type=cache,target=/root/.cache/go-build \ go test -count=1 -timeout 90s -race -parallel 4 -v ./...; exit 1; } # 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. +# make BuildKit build them first, so nothing after them runs unless lint +# and test passed. # golang:1.25.4-alpine3.22, 2026-02-25; the runtime stage uses Alpine 3.22 too FROM golang:1.25.4-alpine3.22@sha256:d3f0cf7723f3429e3f9ed846243970b20a2de7bae6a5b66fc5914e228d831bbb AS builder + +# git for the version, and for govips a C compiler, pkg-config and the +# libvips and libheif headers +RUN apk add --no-cache git build-base pkgconfig vips-dev libheif-dev + +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download ARG CACHEBUST COPY --from=lint /src/go.sum /dev/null COPY --from=test /src/go.sum /dev/null -WORKDIR /src - -# Build dependencies, git among them, and Go modules, as in the test -# phase -COPY script/ ./script/ -COPY go.mod go.sum ./ -RUN script/bootstrap --cgo # A tar-stream context keeps the sender's file owners, which git refuses. RUN git config --system --add safe.directory /src @@ -102,7 +111,6 @@ RUN --mount=type=cache,target=/root/.cache/go-build \ # the libvips and musl it was built with. # alpine:3.22, 2026-10-08 FROM alpine:3.22@sha256:5291449c3df73caf6ed85e649dec1b9e818b39a5d8c871e97afc13e9cd5e8fa8 -ARG CACHEBUST # Install runtime dependencies only. vips-jxl is libvips' JPEG XL # support, without which pixad does not start. @@ -113,6 +121,7 @@ RUN apk add --no-cache \ ca-certificates \ tzdata \ su-exec +ARG CACHEBUST # Copy binary from builder COPY --from=builder /pixad /usr/local/bin/pixad diff --git a/README.md b/README.md index b473c1a..f022607 100644 --- a/README.md +++ b/README.md @@ -102,8 +102,8 @@ it uses libvips through CGO. pixad does not start unless libvips has its JPEG XL support, which on Alpine is the `vips-jxl` package and which the nix and brew packages of libvips include, as do the apt ones from Debian 12 and Ubuntu 24.04 on. Building pixa also needs the development files of libvips and libheif, -`pkg-config` and a C compiler. `script/bootstrap --cgo` installs all of these, -as the `Dockerfile` does where it compiles pixa. Plain `script/bootstrap`, which +`pkg-config` and a C compiler. `script/bootstrap --cgo` installs all of these; +the `Dockerfile` installs its own packages. Plain `script/bootstrap`, which `script/setup` and `script/cibuild` run, installs git, make and Go, and Node, Yarn and the prettier pinned in `yarn.lock` for formatting the markdown, but none of the C libraries: the checks compile pixa in Docker. Docker itself must @@ -831,19 +831,24 @@ provide: `script/precommit` Every `docker build` in these scripts passes a new value of the `CACHEBUST` -build argument, which every stage of the `Dockerfile` declares first, so every -`RUN` step, the lint and test phases included, runs on every build instead of -coming from the build cache. Go's build cache is kept from build to build: the -steps that compile mount it, so a package whose inputs are unchanged is not -compiled again, and both `go test` runs pass `-count=1` so that no stored test -result is replayed. The `test` phase runs on the Debian Go image, with Debian's -libvips and libheif; the image pixa ships is built and runs on Alpine. -`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. `script/fmt` and `script/fmt-check` are -the standard copies with pixa's `gofmt` step kept before prettier. prettier -formats the markdown only, not the HTML templates, as it cannot parse a Go -template action inside a tag. +build argument, which every stage of the `Dockerfile` declares, so every `RUN` +step after that line, the lint and test steps included, runs on every build +instead of coming from the build cache. Above the line are the steps that +install system packages and download the Go modules; Docker keeps their layers +until the base image, the step itself, `go.mod` or `go.sum` changes. Go's build +cache is kept from build to build: the steps that compile mount it, so a package +whose inputs are unchanged is not compiled again, and both `go test` runs pass +`-count=1` so that no stored test result is replayed. The lint step also mounts +golangci-lint's cache, which reuses a package's findings only when its files, +those of the packages it imports, `go.mod`, `.golangci.yml` and the +golangci-lint version are all unchanged. The `test` phase runs on the Debian Go +image, with Debian's libvips and libheif; the image pixa ships is built and runs +on Alpine. `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. `script/fmt` and +`script/fmt-check` are the standard copies with pixa's `gofmt` step kept before +prettier. prettier formats the markdown only, not the HTML templates, as it +cannot parse a Go template action inside a tag. ## Load Test diff --git a/REPO_POLICIES.md b/REPO_POLICIES.md index 5b24098..1067698 100644 --- a/REPO_POLICIES.md +++ b/REPO_POLICIES.md @@ -1,6 +1,6 @@ --- title: Repository Policies -last_modified: 2026-10-15 +last_modified: 2026-10-18 --- This document covers repository structure, tooling, and workflow standards. Code @@ -159,8 +159,11 @@ style conventions are in separate documents: one's findings, and a host-global lock in `$TMPDIR` makes concurrent runs exit non-zero with `parallel golangci-lint is running` — a status a caller cannot tell from real findings. Both have produced wrong verdicts in this - org, in both directions. A container has its own golangci-lint cache, its - own `TMPDIR` and a digest-pinned binary, so neither is reachable. + org, in both directions. A container has its own `TMPDIR`, so the lock is + out of reach. The lint phase does keep golangci-lint's cache between check + builds (below), but it lints every checkout at `/src`, and a finding is + taken from that cache only for the same files, settings and golangci-lint + version. - **Any build that runs checks is given a new `CACHEBUST` value.** Docker invalidates a `COPY` layer only when the copied content changes, so on an @@ -170,15 +173,21 @@ style conventions are in separate documents: id, so no two builds share a value, not even the three in one `script/cibuild`. `script/lint`, `script/test`, `script/cibuild` and `script/docker` are the four, and there is no fifth — `script/check` runs the - two gate phases and `script/fmt-check`, and builds no image of its own. Every - stage of the `Dockerfile` declares `ARG CACHEBUST` as its first line after - `FROM`, and a new value reruns every `RUN` step after that line; `COPY` steps - are compared by content and need nothing. A stage without the `ARG` line is - served from cache on an unchanged tree, so a repository re-vendors the scripts - and adds the `ARG` lines in one commit. The scripts do not pass `--no-cache`: - it reruns every step too, but it also empties every cache mount, and Go's - build cache, which the canonical Go `Dockerfile` below mounts so that compiled - packages survive between check builds, would start empty every time. A bare + two gate phases and `script/fmt-check`, and builds no image of its own. In + every stage of the `Dockerfile`, `ARG CACHEBUST` comes right after the steps + that install system packages and download Go's modules, or right after `FROM` + in a stage with neither, and every step that lints, tests or compiles comes + after it. A step that installs anything else as well, such as a + `script/bootstrap` that also installs node packages, comes after it too. A new + value reruns every `RUN` step after that line; `COPY` steps are compared by + content and need nothing. Docker keeps the layers above the line until the + base image, their own lines or the files they copy change. A stage without the + `ARG` line is served from cache on an unchanged tree, so a repository + re-vendors the scripts and adds the `ARG` lines in one commit. The scripts do + not pass `--no-cache`: it reruns every step too, but it also builds the layers + above the line again and empties every cache mount, so Go's build cache and + golangci-lint's, which the canonical Go `Dockerfile` below mounts so that they + survive between check builds, would start empty every time. A bare `docker build .` is not evidence that anything ran: a sub-second build reporting success is a cache hit, not a result. Never invalidate by pruning — `docker builder prune` and friends destroy a build cache shared with every @@ -194,19 +203,25 @@ style conventions are in separate documents: `Dockerfile`: ```dockerfile - # Every stage declares ARG CACHEBUST first: script/ passes a new value - # on every build, which reruns every RUN step after it. The steps that - # compile mount Go's build cache, which survives from build to build. + # In every stage ARG CACHEBUST comes right after the steps that install + # system packages and download Go's modules, or right after FROM in a + # stage with neither, and every step that lints, tests or compiles + # comes after it. script/ passes a new value on every build, which + # reruns every RUN step after it. Docker keeps the layers above it + # until the base image, their own lines or go.mod and go.sum change. + # The steps that compile mount Go's build cache, and golangci-lint run + # also mounts golangci-lint's; both survive from build to build. # Lint phase # golangci/golangci-lint:v2.x.x, YYYY-MM-DD FROM golangci/golangci-lint@sha256:... AS lint - ARG CACHEBUST WORKDIR /src COPY go.mod go.sum ./ RUN go mod download + ARG CACHEBUST COPY . . RUN --mount=type=cache,target=/root/.cache/go-build \ + --mount=type=cache,target=/root/.cache/golangci-lint \ golangci-lint run --config .golangci.yml ./... # Test phase. -race needs cgo and so a C compiler, which the Debian Go @@ -214,10 +229,10 @@ style conventions are in separate documents: # cache also holds test results, which go test would otherwise replay. # golang:1.x, YYYY-MM-DD FROM golang@sha256:... AS test - ARG CACHEBUST WORKDIR /src COPY go.mod go.sum ./ RUN go mod download + ARG CACHEBUST COPY . . RUN --mount=type=cache,target=/root/.cache/go-build \ go test -count=1 -timeout 90s -race -cover ./... || \ @@ -225,19 +240,19 @@ style conventions are in separate documents: go test -count=1 -timeout 90s -race -v ./...; exit 1; } # Build stage. Nothing is wanted from either phase above; the copies - # are what make BuildKit build them first, so this stage cannot run - # unless lint and test passed. + # are what make BuildKit build them first, so nothing after them can + # run unless lint and test passed. # golang:1.x-alpine, YYYY-MM-DD FROM golang@sha256:... AS builder - ARG CACHEBUST - COPY --from=lint /src/go.sum /dev/null - COPY --from=test /src/go.sum /dev/null RUN apk add --no-cache git - # A tar-stream context keeps the sender's file owners, which git refuses. - RUN git config --system --add safe.directory /src WORKDIR /src COPY go.mod go.sum ./ RUN go mod download + ARG CACHEBUST + COPY --from=lint /src/go.sum /dev/null + COPY --from=test /src/go.sum /dev/null + # A tar-stream context keeps the sender's file owners, which git refuses. + RUN git config --system --add safe.directory /src COPY . . # The VERSION build arg when one is given, otherwise @@ -274,16 +289,26 @@ style conventions are in separate documents: - Keep the runtime stage last, and if you add a stage after it, give it the same two copies. A plain `docker build .` builds the last stage's chain and nothing else. - - Only Go's build cache is mounted (`/root/.cache/go-build`, where Go keeps - it for root), and only on the steps that compile: `golangci-lint run`, - both `go test` runs and `go build`. It keeps compiled packages between - check builds, and Go reuses one only when all its inputs are unchanged, so - it saves the compile without skipping a check. Nothing else is kept: - golangci-lint's own cache would replay stored findings in place of a lint - run, and Go builds from whatever files a kept module cache holds without - checking them again (only `go mod verify` does), so a file changed there - would go unnoticed. Go's build cache also holds test results, which is why - both `go test` runs carry `-count=1`. + - Check builds keep these, and none of them can hand back a stale result: + - The layers above `ARG CACHEBUST`: the system packages and Go's + modules. Docker installs the packages again when the base image or the + install line changes, and downloads the modules again when `go.mod` or + `go.sum` changes. Go checks every module against `go.sum` as it + downloads it, and no later build can change a kept layer. The modules + are kept in a layer rather than a cache mount because a cache mount is + shared by every build on the host, and Go builds from whatever files + it holds without checking them again (only `go mod verify` does), so a + file changed there would go unnoticed. + - Go's build cache (`/root/.cache/go-build`, where Go keeps it for + root), mounted on the steps that compile: `golangci-lint run`, both + `go test` runs and `go build`. Go reuses a compiled package only when + all its inputs are unchanged. The cache also holds test results, which + is why both `go test` runs carry `-count=1`. + - golangci-lint's cache (`/root/.cache/golangci-lint`, where the pinned + image keeps it), mounted on `golangci-lint run`. golangci-lint reuses + a package's findings only when the package's files and those of every + package it imports, `go.mod`, the linters and their settings in + `.golangci.yml`, and the golangci-lint version are all unchanged. - If the project uses `//go:embed` directives that reference build artifacts (e.g. a web frontend compiled in a separate stage), the lint phase must create placeholder files so the embed directives resolve. Example: @@ -292,12 +317,20 @@ style conventions are in separate documents: in the lint phase. The `golangci/golangci-lint` image is Debian-based and has no `apk`, so install with `apt-get` under the Debian package name (`libvips-dev`, where alpine says `vips-dev`), and delete the package - lists in the same `RUN`, so the layer does not keep them: + lists in the same `RUN`, so the layer does not keep them. The test phase, + on the Debian Go image, installs the libraries its tests need the same + way. Either install goes right after `FROM`, above `ARG CACHEBUST`, so + Docker keeps it until the base image or the install line changes: ```dockerfile + FROM golangci/golangci-lint@sha256:... AS lint 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 + ARG CACHEBUST ``` - `.dockerignore` lets `.git` into the build context. It keeps out every git diff --git a/TODO.md b/TODO.md index 44ae4c1..768d7f9 100644 --- a/TODO.md +++ b/TODO.md @@ -30,6 +30,10 @@ P2: security: per-origin rate limiting # Completed Steps +- 2026-10-18 re-vendored from `sneak/prompts` commit `0653579` (closes #221): + each `Dockerfile` stage installs its system packages and downloads the Go + modules above `ARG CACHEBUST`, so check builds keep them, and the lint step + keeps golangci-lint's cache. - 2026-10-18 the next reconciliation pass waits at least ten times as long as the last one took, so passes on a large cache no longer run back to back (closes #250). diff --git a/script/bootstrap b/script/bootstrap index 8fd0cbe..e3fe598 100755 --- a/script/bootstrap +++ b/script/bootstrap @@ -19,9 +19,8 @@ # CGO image libraries (pkg-config, vips # with its JPEG XL support, libheif) for # the govips bindings instead of Node: to -# compile pixa, in the Dockerfile's test -# phase and build stage, which format -# nothing +# compile pixa outside Docker, as +# `make build` does set -eu ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" diff --git a/script/cibuild b/script/cibuild index 66fcca0..2656771 100755 --- a/script/cibuild +++ b/script/cibuild @@ -2,11 +2,12 @@ # 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. -# A new CACHEBUST value reruns every RUN step, since a cached gate phase -# is a check that did not run; unlike --no-cache, it keeps Go's build cache. +# A new CACHEBUST value reruns every RUN step after the ARG CACHEBUST +# line, since a cached gate phase is a check that did not run; unlike +# --no-cache, it keeps the layers above that line and the cache mounts. # This relies on every stage of the Dockerfile declaring ARG CACHEBUST -# first; a stage without that line is served from cache on an unchanged -# tree, with no warning. +# before every step that lints, tests or compiles; a stage without that +# line is served from cache on an unchanged tree, with no warning. set -eu SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" diff --git a/script/docker b/script/docker index 360037a..cc00d9c 100755 --- a/script/docker +++ b/script/docker @@ -1,11 +1,12 @@ #!/bin/sh # script/docker: build the Docker image tagged with the project name. # Identical in all repos; the tag comes from script/projectname. -# A new CACHEBUST value reruns every RUN step, since a cached gate phase -# is a check that did not run; unlike --no-cache, it keeps Go's build cache. +# A new CACHEBUST value reruns every RUN step after the ARG CACHEBUST +# line, since a cached gate phase is a check that did not run; unlike +# --no-cache, it keeps the layers above that line and the cache mounts. # This relies on every stage of the Dockerfile declaring ARG CACHEBUST -# first; a stage without that line is served from cache on an unchanged -# tree, with no warning. +# before every step that lints, tests or compiles; a stage without that +# line is served from cache on an unchanged tree, with no warning. set -eu SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" diff --git a/script/lint b/script/lint index c4c459e..451a067 100755 --- a/script/lint +++ b/script/lint @@ -5,11 +5,13 @@ # make its answer untrustworthy. # # The phase is not the last stage in the file, so it is built only when -# --target names it. A new CACHEBUST value reruns every RUN step, since -# a cached lint layer is a lint that did not run; unlike --no-cache, it -# keeps Go's build cache. This relies on every stage of the Dockerfile -# declaring ARG CACHEBUST first; a stage without that line is served -# from cache on an unchanged tree, with no warning. +# --target names it. A new CACHEBUST value reruns every RUN step after +# the ARG CACHEBUST line, since a cached lint layer is a lint that did +# not run; unlike --no-cache, it keeps the layers above that line and +# the cache mounts. This relies on every stage of the Dockerfile +# declaring ARG CACHEBUST before every step that lints, tests or +# compiles; a stage without that line is served from cache on an +# unchanged tree, with no warning. # --output type=cacheonly writes no image, since nothing uses one. set -eu diff --git a/script/test b/script/test index a56c4a1..1622a11 100755 --- a/script/test +++ b/script/test @@ -3,11 +3,12 @@ # 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, and a new CACHEBUST value because a cached test layer is a test -# that did not run; unlike --no-cache, it keeps Go's build cache. This -# relies on every stage of the Dockerfile declaring ARG CACHEBUST first; -# a stage without that line is served from cache on an unchanged tree, -# with no warning. --output type=cacheonly writes no image, since -# nothing uses one. +# that did not run; unlike --no-cache, it keeps the layers above the ARG +# CACHEBUST line and the cache mounts. This relies on every stage of the +# Dockerfile declaring ARG CACHEBUST before every step that lints, tests +# or compiles; a stage without that line is served from cache on an +# unchanged tree, with no warning. --output type=cacheonly writes no +# image, since nothing uses one. set -eu SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"