1 Commits
Author SHA1 Message Date
sneak c739722fa0 Test that eviction ticks read nothing for reconciliation
check / check (push) Canceled after 0s
The eviction loop runs the reconciliation pass on every periodic tick,
so this test fails until the pass runs at startup and then once a day.

Model: opus-5-5
2026-10-15 20:31:59 +00:00
21 changed files with 262 additions and 576 deletions
+9 -18
View File
@@ -13,21 +13,12 @@
# `/myapp`, never `**/myapp`, which also matches `cmd/myapp/` and # `/myapp`, never `**/myapp`, which also matches `cmd/myapp/` and
# deletes the package directory from the context. # deletes the package directory from the context.
# .git is sent without its config. Without a VERSION build argument the # Unlike the standard file, which leaves out all of .git, pixa sends
# stage that compiles runs `git describe --tags --always` on .git, which # .git without its config. Without a VERSION build argument the stage
# does not need .git/config; that file can hold a credential, such as a # that compiles runs `git describe --tags --always` on .git, which does
# not need .git/config; that file can hold a credential, such as a
# password in a remote URL or the token the CI checkout step stores there. # password in a remote URL or the token the CI checkout step stores there.
# Each submodule keeps a config with the same exposure in its git directory .git/config
# under .git/modules/, nested again for a submodule's own submodules, or in
# its own .git directory when it keeps one.
# KNOWN GAP: a submodule whose name has a `config` segment (`config`,
# `deploy/config`, `config/lib`) loses its whole git directory, because
# `**/.git/modules/**/config` also matches that segment's directory
# under .git/modules/. Go's version stamping then fails the build;
# nothing leaks. Name such a submodule without that segment:
# `git submodule add --name`.
**/.git/config
**/.git/modules/**/config
# Agent scratch: one full checkout of the repo per in-flight agent. # Agent scratch: one full checkout of the repo per in-flight agent.
# Anchored because it occurs once where agents run at the repo root. # Anchored because it occurs once where agents run at the repo root.
@@ -51,9 +42,7 @@
**/[iI][dD]_[rR][sS][aA] **/[iI][dD]_[rR][sS][aA]
**/[iI][dD]_[dD][sS][aA] **/[iI][dD]_[dD][sS][aA]
**/[iI][dD]_[eE][cC][dD][sS][aA] **/[iI][dD]_[eE][cC][dD][sS][aA]
**/[iI][dD]_[eE][cC][dD][sS][aA]_[sS][kK]
**/[iI][dD]_[eE][dD]25519 **/[iI][dD]_[eE][dD]25519
**/[iI][dD]_[eE][dD]25519_[sS][kK]
# Dependencies: restored inside the image, never copied in. # Dependencies: restored inside the image, never copied in.
**/node_modules **/node_modules
@@ -71,8 +60,10 @@
**/.vscode **/.vscode
**/*.sublime-* **/*.sublime-*
# pixa's own entries. On the host, `make build` writes bin/pixad, and the # pixa's own entries. Nothing in the build reads .gitignore. On the
# example config keeps its state directory in data/. # host, `make build` writes bin/pixad, and the example config keeps its
# state directory in data/.
.gitignore
/bin /bin
/data /data
-3
View File
@@ -13,6 +13,3 @@ indent_style = tab
[*.go] [*.go]
indent_style = tab indent_style = tab
# This repository's own sections, such as one for another language it
# uses, go below this comment, and a re-vendor keeps them.
+3 -9
View File
@@ -1,21 +1,15 @@
name: check name: check
on: [push] on: [push]
# Free the shared runner: a new push cancels only the same branch's older run.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs: jobs:
check: check:
runs-on: ubuntu-latest runs-on: ubuntu-latest
# Free the shared runner from a hung build.
timeout-minutes: 20
steps: steps:
# actions/checkout v4.2.2, 2026-02-22 # actions/checkout v4.2.2, 2026-02-22
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
# script/cibuild needs no token, so none is left in .git/config. # The default clone is shallow and has no tags, so the
# version the build takes from `git describe` would be a
# bare commit; this fetches the whole history with its tags.
with: with:
persist-credentials: false
# All history and tags, so git describe finds the version tag.
fetch-depth: 0 fetch-depth: 0
- run: script/cibuild - run: script/cibuild
- run: script/docker-smoke - run: script/docker-smoke
+6 -37
View File
@@ -17,46 +17,15 @@ Thumbs.db
# entry and must not be given a `**/` prefix on the way into one. # entry and must not be given a `**/` prefix on the way into one.
.claude/ .claude/
# Node # Environment / secrets
node_modules/ .env
.env.*
# Secrets. Unanchored like every entry above, so each matches at every *.pem
# depth. Matching is case-sensitive on Linux, so names use character *.key
# ranges rather than a lowercase form that misses `Server.Key`.
# Environment files. `*.env` covers bare `.env` and the `prod.env`
# convention. Only the templates `example.env` and `sample.env` are
# re-included below. A repository that commits any other template adds
# its own negation at the end of this file, for example `!.env.example`.
*.[eE][nN][vV]
.[eE][nN][vV].*
.[eE][nN][vV][rR][cC]
!example.env
!sample.env
# Private keys and the bundles carrying them.
*.[pP][eE][mM]
*.[kK][eE][yY]
*.[pP]12
*.[pP][fF][xX]
[iI][dD]_[rR][sS][aA]
[iI][dD]_[dD][sS][aA]
[iI][dD]_[eE][cC][dD][sS][aA]
[iI][dD]_[eE][cC][dD][sS][aA]_[sS][kK]
[iI][dD]_[eE][dD]25519
[iI][dD]_[eE][dD]25519_[sS][kK]
# This repository's own entries, such as its build outputs, go below
# this comment, and a re-vendor keeps them. Anchor a binary built at the
# root: `/myapp`, never `myapp`, which also ignores `cmd/myapp/`.
# Go
*.log
*.out
*.test
# Dependencies # Dependencies
vendor/ vendor/
node_modules/
# Build output # Build output
/bin/ /bin/
-3
View File
@@ -17,7 +17,6 @@ linters:
disable: disable:
# Genuinely incompatible with project patterns # Genuinely incompatible with project patterns
- exhaustruct # Requires all struct fields - exhaustruct # Requires all struct fields
- exhaustruct_v5 # Requires all struct fields (successor to exhaustruct)
- godot # Requires comments to end with periods - godot # Requires comments to end with periods
- wrapcheck # Too verbose for internal packages - wrapcheck # Too verbose for internal packages
- varnamelen # Short names like db, id are idiomatic Go - varnamelen # Short names like db, id are idiomatic Go
@@ -25,8 +24,6 @@ linters:
# silenced by disabling that name, not by enabling the successor. # silenced by disabling that name, not by enabling the successor.
- wsl # Deprecated, replaced by wsl_v5 - wsl # Deprecated, replaced by wsl_v5
- gomodguard # Deprecated, replaced by gomodguard_v2 - gomodguard # Deprecated, replaced by gomodguard_v2
# Misses findings at random in v2.14.0; back once a pinned release fixes it
- canonicalheader
settings: settings:
lll: lll:
line-length: 88 line-length: 88
+5
View File
@@ -1,2 +1,7 @@
node_modules/ node_modules/
yarn.lock yarn.lock
# A byte-for-byte copy of the one in sneak/prompts.
REPO_POLICIES.md
vendor/
View File
+30 -48
View File
@@ -1,12 +1,7 @@
# 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.
# Lint phase. script/lint builds it alone. The linter is run directly: # Lint phase. script/lint builds it alone. The linter is run directly:
# `make lint` and script/lint are themselves a docker build. # `make lint` and script/lint are themselves a docker build.
# golangci/golangci-lint:v2.14.0, 2026-10-15 # golangci/golangci-lint:v2.12.2, 2026-10-04
FROM golangci/golangci-lint@sha256:ad862ba6b3798cbe0fd9fd7408d498fd74fbd2623a92406b2fd3898faf0bf98f AS lint FROM golangci/golangci-lint@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS lint
ARG CACHEBUST
# The linter compiles every package, and govips needs the libvips # The linter compiles every package, and govips needs the libvips
# headers for that. REPO_POLICIES.md has the lint phase install them # headers for that. REPO_POLICIES.md has the lint phase install them
@@ -19,20 +14,17 @@ WORKDIR /src
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY . . COPY . .
RUN --mount=type=cache,target=/root/.cache/go-build \ RUN golangci-lint run --config .golangci.yml ./...
golangci-lint run --config .golangci.yml ./...
# Test phase. script/test builds it alone. -race needs cgo and so a C # Test phase. script/test builds it alone.
# compiler, which the Debian Go image ships and the alpine one does not. # golang:1.25.4-alpine3.22, 2026-02-25; the runtime stage uses Alpine 3.22 too
# golang:1.25.4-trixie, 2026-10-15 FROM golang:1.25.4-alpine3.22@sha256:d3f0cf7723f3429e3f9ed846243970b20a2de7bae6a5b66fc5914e228d831bbb AS test
FROM golang@sha256:a02d35efc036053fdf0da8c15919276bf777a80cbfda6a35c5e9f087e652adfc AS test
ARG CACHEBUST
WORKDIR /src WORKDIR /src
# script/bootstrap --cgo installs the build dependencies (the libvips # script/bootstrap --cgo installs the build dependencies (a C compiler,
# and libheif headers, and libvips' JPEG XL support, which the tests # the libvips and libheif headers, and libvips' JPEG XL support, which
# need) and downloads the Go modules. # the tests need) and downloads the Go modules.
COPY script/ ./script/ COPY script/ ./script/
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN script/bootstrap --cgo RUN script/bootstrap --cgo
@@ -40,14 +32,11 @@ RUN script/bootstrap --cgo
COPY . . COPY . .
# Without -v first; on a failure, again with -v for the details, and # Without -v first; on a failure, again with -v for the details, and
# the step fails even if the second run passes. -count=1 because the # the step fails even if the second run passes. -parallel 4: by default
# mounted cache also holds test results, which go test would otherwise # a package runs as many of its tests at once as the host has CPUs, and
# replay. -parallel 4: by default a package runs as many of its tests at # on a busy host they then wait so long to be scheduled that a test
# once as the host has CPUs, and on a busy host they then wait so long # that times a request can see it return a second late.
# to be scheduled that a test that times a request can see it return a RUN go test -count=1 -timeout 90s -race -parallel 4 -cover ./... || \
# second late.
RUN --mount=type=cache,target=/root/.cache/go-build \
go test -count=1 -timeout 90s -race -parallel 4 -cover ./... || \
{ echo "--- Rerunning with -v for details ---"; \ { echo "--- Rerunning with -v for details ---"; \
go test -count=1 -timeout 90s -race -parallel 4 -v ./...; exit 1; } go test -count=1 -timeout 90s -race -parallel 4 -v ./...; exit 1; }
@@ -56,44 +45,38 @@ RUN --mount=type=cache,target=/root/.cache/go-build \
# test passed. # test passed.
# golang:1.25.4-alpine3.22, 2026-02-25; the runtime stage uses Alpine 3.22 too # 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 FROM golang:1.25.4-alpine3.22@sha256:d3f0cf7723f3429e3f9ed846243970b20a2de7bae6a5b66fc5914e228d831bbb AS builder
ARG CACHEBUST
COPY --from=lint /src/go.sum /dev/null COPY --from=lint /src/go.sum /dev/null
COPY --from=test /src/go.sum /dev/null COPY --from=test /src/go.sum /dev/null
WORKDIR /src WORKDIR /src
# Build dependencies, git among them, and Go modules, as in the test # Build dependencies and Go modules, as in the test phase
# phase
COPY script/ ./script/ COPY script/ ./script/
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN script/bootstrap --cgo 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
# Copy source code # Copy source code
COPY . . COPY . .
# The VERSION build arg when one is given, otherwise # VERSION is declared here, not earlier: a new value reruns only the
# `git describe --tags --always` on the .git in the build context: the # build, not script/bootstrap. Given none, the version is
# tag on a tagged commit, tag-N-gHASH after one, the short commit when # `git describe --tags --always` of the .git in the build context (git
# no tag is reachable. With .git present, a version that is still empty, # comes from script/bootstrap): the tag on a tagged commit, tag-N-gHASH
# dev or unknown fails the build: git is missing or could not read the # after one, the short commit when no tag is reachable. A context that
# checkout. A context without .git, as from a source tarball, stamps an # carries .git and still yields no version fails the build; one without
# empty version. CGO stays enabled for govips; -trimpath keeps build # .git, as from a source tarball, stamps an empty version. CGO stays
# paths out of the binary, and -s -w leave out the symbol table and # enabled for govips; -trimpath keeps build paths out of the binary, and
# debug information. # -s -w leave out the symbol table and debug information.
ARG VERSION ARG VERSION
RUN --mount=type=cache,target=/root/.cache/go-build \ RUN version="${VERSION:-$(git describe --tags --always)}"; \
VERSION="${VERSION:-$(git describe --tags --always)}"; \ if [ -e .git ] && { [ -z "$version" ] || [ "$version" = dev ] || \
if [ -e .git ]; then \ [ "$version" = unknown ]; }; then \
case "$VERSION" in ""|dev|unknown) \ echo "the build context carries .git but yields no version" >&2; \
echo "version is '$VERSION' although .git is present" >&2; \ exit 1; \
exit 1 ;; \
esac; \
fi; \ fi; \
CGO_ENABLED=1 GOTOOLCHAIN=auto go build -trimpath \ CGO_ENABLED=1 GOTOOLCHAIN=auto go build -trimpath \
-ldflags="-s -w -X main.Version=${VERSION}" \ -ldflags "-s -w -X main.Version=${version}" \
-o /pixad ./cmd/pixad -o /pixad ./cmd/pixad
# Runtime stage, and the last one: a plain `docker build .` builds this # Runtime stage, and the last one: a plain `docker build .` builds this
@@ -102,7 +85,6 @@ RUN --mount=type=cache,target=/root/.cache/go-build \
# the libvips and musl it was built with. # the libvips and musl it was built with.
# alpine:3.22, 2026-10-08 # alpine:3.22, 2026-10-08
FROM alpine:3.22@sha256:5291449c3df73caf6ed85e649dec1b9e818b39a5d8c871e97afc13e9cd5e8fa8 FROM alpine:3.22@sha256:5291449c3df73caf6ed85e649dec1b9e818b39a5d8c871e97afc13e9cd5e8fa8
ARG CACHEBUST
# Install runtime dependencies only. vips-jxl is libvips' JPEG XL # Install runtime dependencies only. vips-jxl is libvips' JPEG XL
# support, without which pixad does not start. # support, without which pixad does not start.
+8 -13
View File
@@ -168,7 +168,7 @@ references rather than using filesystem refcounting.
Source and transformed image files, and the `.meta` file beside each transformed Source and transformed image files, and the `.meta` file beside each transformed
image, are written under a temporary name, synced to disk, and only then renamed image, are written under a temporary name, synced to disk, and only then renamed
into place, so a crash leaves no file under its own name with part of its data. into place, so a crash leaves no file under its own name with part of its data.
When pixa starts, and once a day after, it removes each cached source or When pixa starts, and every five minutes after, it removes each cached source or
transformed image whose file is empty, or not the size the database records for transformed image whose file is empty, or not the size the database records for
it, with a warning naming the file; the next request for it fetches the source it, with a warning naming the file; the next request for it fetches the source
or converts the image again. or converts the image again.
@@ -706,9 +706,9 @@ provide:
install-precommit) install-precommit)
- `script/projectname` — output the project name ("pixa") - `script/projectname` — output the project name ("pixa")
- `script/test` — run the test suite: build the `test` phase of the - `script/test` — run the test suite: build the `test` phase of the
`Dockerfile`, writing no image `Dockerfile`, tagged `pixa-test`
- `script/lint` — run golangci-lint: build the `lint` phase of the `Dockerfile`, - `script/lint` — run golangci-lint: build the `lint` phase of the `Dockerfile`,
writing no image; the linter never runs on the host tagged `pixa-lint`; the linter never runs on the host
- `script/fmt` — format the Go code with gofmt and the markdown with prettier - `script/fmt` — format the Go code with gofmt and the markdown with prettier
(writes) (writes)
- `script/fmt-check` — check the same formatting (read-only), on the host - `script/fmt-check` — check the same formatting (read-only), on the host
@@ -726,20 +726,15 @@ provide:
- `script/install-precommit` — install the git pre-commit hook that runs - `script/install-precommit` — install the git pre-commit hook that runs
`script/precommit` `script/precommit`
Every `docker build` in these scripts passes a new value of the `CACHEBUST` Every `docker build` in these scripts passes `--no-cache`, so the lint and test
build argument, which every stage of the `Dockerfile` declares first, so every phases run on every build instead of coming from the build cache.
`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/check`, `script/cibuild`, `script/docker`, `script/lint`, `script/test`,
`script/setup` and `script/install-precommit` are the standard copies from `script/setup` and `script/install-precommit` are the standard copies from
`sneak/prompts`, kept identical to them. `script/fmt` and `script/fmt-check` are `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 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 formats the markdown only: not the HTML templates, as it cannot parse a Go
template action inside a tag. template action inside a tag, and not `REPO_POLICIES.md` (see
`.prettierignore`), a copy of the one in `sneak/prompts`.
## Load Test ## Load Test
+105 -258
View File
@@ -1,6 +1,6 @@
--- ---
title: Repository Policies title: Repository Policies
last_modified: 2026-10-15 last_modified: 2026-09-08
--- ---
This document covers repository structure, tooling, and workflow standards. Code This document covers repository structure, tooling, and workflow standards. Code
@@ -54,39 +54,34 @@ style conventions are in separate documents:
`cibuild`. `script/bootstrap` installs all dependencies idempotently and `cibuild`. `script/bootstrap` installs all dependencies idempotently and
assumes nothing is present: base tools come from nix, apt, brew, or apk assumes nothing is present: base tools come from nix, apt, brew, or apk
(detected in that order; apt runs noninteractive). For node it uses the (detected in that order; apt runs noninteractive). For node it uses the
installed node only when its major version is the pinned one; otherwise (node installed node if present; otherwise it installs a PINNED node version via
missing, or another major, such as the node 24 the CI runner image ships) it nvm, first installing nvm itself if missing — from a hash-verified GitHub
installs the PINNED node version via nvm and installs yarn and the packages release archive (never `curl | sh`), with bash installed as an explicit
under it. nvm itself is installed first if missing, from a hash-verified prerequisite since nvm requires bash. yarn is then pinned via
GitHub release archive (never `curl | sh`), with bash installed as an explicit `corepack prepare yarn@<version> --activate`. Never install "latest" or "lts";
prerequisite since nvm requires bash. Only the major version is compared always exact versions. `script/cibuild` runs the CI build: it changes to the
because the `Dockerfile` stages start from a node image whose exact version is repo root, runs `script/bootstrap`, runs `script/check`, and builds the image
not the pin, and nvm cannot install a prebuilt node on alpine. yarn is pinned with the version; the Gitea workflow calls it. **`script/cibuild` runs
via `corepack prepare yarn@<version> --activate`. Never install "latest" or
"lts"; always exact versions. `script/cibuild` runs the CI build: it changes
to the repo root, runs `script/bootstrap`, runs `script/check`, and builds the
image with the version; the Gitea workflow calls it. **`script/cibuild` runs
`script/bootstrap` first**, because the workflow checks out the repo and runs `script/bootstrap` first**, because the workflow checks out the repo and runs
nothing else, while `script/fmt-check` runs the formatter on the host: on a nothing else, while `script/fmt-check` runs the formatter on the host: on a
pristine checkout with nothing installed the run dies there, after the pristine checkout with nothing installed the run dies there, after the
containerised gates have passed. **The bootstrap alone is not enough**: when containerised gates have passed. **The bootstrap alone is not enough**:
`script/bootstrap` installs node and yarn under nvm it leaves neither on the `script/bootstrap` installs node and yarn under nvm and leaves neither on the
`PATH` of the shell that called it, so a bare `yarn` either exits 127 or runs `PATH` of the shell that called it, so a bare `yarn` still exits 127. The host
under another node. The host entrypoints that need yarn — `script/fmt` and entrypoints that need yarn — `script/fmt` and `script/fmt-check` — therefore
`script/fmt-check` — therefore use the same test as `script/bootstrap`: when source nvm for the pinned node version before invoking it, exactly as
the node on `PATH` has the pinned major version they run the `yarn` on `PATH`, `script/bootstrap`'s own install step does. A runner carrying nothing but
and otherwise they load nvm through `$HOME/.nvm/nvm.sh` and run yarn under the docker and git then gets through `script/check`. Four further scripts are our
pinned node. A runner carrying nothing but docker and git then gets through own extensions to the standard: `script/check` runs `script/test`,
`script/check`. Four further scripts are our own extensions to the standard: `script/lint` and `script/fmt-check`; `script/precommit` is what the git
`script/check` runs `script/test`, `script/lint` and `script/fmt-check`; pre-commit hook runs, and it calls `script/check`; `script/install-precommit`
`script/precommit` is what the git pre-commit hook runs, and it calls installs the git pre-commit hook (the `make hooks` target shims to it); and
`script/check`; `script/install-precommit` installs the git pre-commit hook `script/projectname` (literally that filename) simply outputs the project's
(the `make hooks` target shims to it); and `script/projectname` (literally name. Scripts that need the name call `script/projectname` — e.g.
that filename) simply outputs the project's name. Scripts that need the name `script/docker` assembles its image tag from it — so those scripts stay
call `script/projectname` — e.g. `script/docker` assembles its image tag from byte-identical across all repos. Repo-type-specific pre-commit extras (e.g.
it — so those scripts stay byte-identical across all repos. Repo-type-specific `go mod tidy` verification in Go repos) belong in `script/precommit`, not in
pre-commit extras (e.g. `go mod tidy` verification in Go repos) belong in the hook itself. Model scripts are at
`script/precommit`, not in the hook itself. Model scripts are at
`https://git.eeqj.de/sneak/prompts/raw/branch/main/script/<name>`. The README `https://git.eeqj.de/sneak/prompts/raw/branch/main/script/<name>`. The README
must document the provided scripts in an **Entrypoints** section (see the must document the provided scripts in an **Entrypoints** section (see the
README requirements below). README requirements below).
@@ -109,24 +104,18 @@ style conventions are in separate documents:
`lint` phase and a `test` phase, with the final stage depending on both so the `lint` phase and a `test` phase, with the final stage depending on both so the
image cannot be built unless they pass. For non-server repos the final stage image cannot be built unless they pass. For non-server repos the final stage
brings up a development environment; for server repos it is the runtime image. brings up a development environment; for server repos it is the runtime image.
The gate phases and the build stage start from their pinned base images and Dockerfiles install development prerequisites by running `script/bootstrap`
install what those images lack either inline, as the canonical Go `Dockerfile` rather than duplicating installs inline; COPY `script/` and the dependency
below does for `git`, or by running `script/bootstrap`, as the `prompts` manifests (`package.json` + `yarn.lock`, `go.mod` + `go.sum`, etc.) before
repo's own `Dockerfile` does for its yarn packages. The development running it.
environment stage installs development prerequisites by running
`script/bootstrap` rather than duplicating its installs inline. A stage that
runs `script/bootstrap` COPYs `script/` and the dependency manifests
(`package.json` + `yarn.lock`, `go.mod` + `go.sum`, etc.) before running it.
- **Linting and testing run in Docker, as phases of the `Dockerfile`.** There is - **Linting and testing run in Docker, as phases of the `Dockerfile`.** There is
no separate lint file. `script/lint` and `script/test` each build one phase no separate lint file. `script/lint` and `script/test` each build one phase
and nothing else: and nothing else:
```sh ```sh
docker build --build-arg CACHEBUST="$(date +%s)-$$" \ docker build --no-cache --target lint -t "$(script/projectname)-lint" .
--target lint --output type=cacheonly . docker build --no-cache --target test -t "$(script/projectname)-test" .
docker build --build-arg CACHEBUST="$(date +%s)-$$" \
--target test --output type=cacheonly .
``` ```
**A stage that is not the last one in the file is built only when the final **A stage that is not the last one in the file is built only when the final
@@ -136,15 +125,10 @@ style conventions are in separate documents:
plain `docker build .` builds the last stage alone and exits 0 having linted plain `docker build .` builds the last stage alone and exits 0 having linted
and tested nothing. and tested nothing.
**The gate builds write no image.** With `--output type=cacheonly` the phase **Every `docker build` in `script/` is tagged**, here and in
runs and a failing step fails the build, but the result is not exported. `script/cibuild` and `script/docker`. An untagged build leaves a dangling
Nothing uses those images, and writing one out is slow: a Go test phase's image behind on every invocation, on every developer host and every CI
image holds the toolchain. A build given neither `--output` nor `-t` writes runner; a tagged one replaces the previous image.
an untagged image and leaves it dangling, on every developer host and every
CI runner. `script/cibuild` and `script/docker` build the image that ships
and tag it, so each build replaces the previous image; each assigns the tag
on its own line before the build, so `set -e` stops it where
`script/projectname` fails.
Inside a phase the tool is invoked directly — `golangci-lint`, `go test`, Inside a phase the tool is invoked directly — `golangci-lint`, `go test`,
`eslint`, `prettier` — never through `make lint` or `script/test`, which are `eslint`, `prettier` — never through `make lint` or `script/test`, which are
@@ -159,107 +143,66 @@ style conventions are in separate documents:
one's findings, and a host-global lock in `$TMPDIR` makes concurrent runs 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 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 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 org, in both directions. A container has its own cache, its own `TMPDIR` and
own `TMPDIR` and a digest-pinned binary, so neither is reachable. a digest-pinned binary, so neither is reachable.
- **Any build that runs checks is given a new `CACHEBUST` value.** Docker - **Any build that runs checks is built with `--no-cache`.** Docker invalidates
invalidates a `COPY` layer only when the copied content changes, so on an a `COPY` layer only when the copied content changes, so on an unchanged tree
unchanged tree the check `RUN` is served from cache, nothing executes, and the the check `RUN` is served from cache, nothing executes, and the build still
build still exits 0. Every `docker build` in `script/` therefore passes exits 0. Every `docker build` in `script/` therefore passes `--no-cache`:
`--build-arg CACHEBUST="$(date +%s)-$$"`, the time and the script's process `script/lint`, `script/test`, `script/cibuild` and `script/docker` are the
id, so no two builds share a value, not even the three in one four, and there is no fifth — `script/check` runs the two gate phases and
`script/cibuild`. `script/lint`, `script/test`, `script/cibuild` and `script/fmt-check`, and builds no image of its own. A bare `docker build .` is
`script/docker` are the four, and there is no fifth — `script/check` runs the not evidence that anything ran: a sub-second build reporting success is a
two gate phases and `script/fmt-check`, and builds no image of its own. Every cache hit, not a result. Never invalidate by pruning — `docker builder prune`
stage of the `Dockerfile` declares `ARG CACHEBUST` as its first line after and friends destroy a build cache shared with every other build on the host.
`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
`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
other build on the host. When a check is added or changed, prove it works by
planting a defect it must catch and watching the run fail on it, then revert
the defect. A green run alone shows neither that the check ran nor that it
covers what it should.
- **The gate phases are separate stages, and the build stage depends on both.** - **The gate phases are separate stages, and the build stage depends on both.**
The lint phase is based on the `golangci/golangci-lint` image (pinned by The lint phase is based on the `golangci/golangci-lint` image (pinned by
hash), so lint failures surface in seconds rather than after a full compile, hash), so lint failures surface in seconds rather than after a full compile,
and the test phase is based on the Debian Go image. The canonical Go repo and the test phase is based on the Go image. The canonical Go repo
`Dockerfile`: `Dockerfile`:
```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.
# Lint phase # Lint phase
# golangci/golangci-lint:v2.x.x, YYYY-MM-DD # golangci/golangci-lint:v2.x.x, YYYY-MM-DD
FROM golangci/golangci-lint@sha256:... AS lint FROM golangci/golangci-lint@sha256:... AS lint
ARG CACHEBUST
WORKDIR /src WORKDIR /src
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY . . COPY . .
RUN --mount=type=cache,target=/root/.cache/go-build \ RUN golangci-lint run --config .golangci.yml ./...
golangci-lint run --config .golangci.yml ./...
# Test phase. -race needs cgo and so a C compiler, which the Debian Go # Test phase
# image ships and the alpine one does not. -count=1 because the mounted # golang:1.x-alpine, YYYY-MM-DD
# cache also holds test results, which go test would otherwise replay.
# golang:1.x, YYYY-MM-DD
FROM golang@sha256:... AS test FROM golang@sha256:... AS test
ARG CACHEBUST
WORKDIR /src WORKDIR /src
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY . . COPY . .
RUN --mount=type=cache,target=/root/.cache/go-build \ RUN go test -timeout 90s -race -cover ./... || \
go test -count=1 -timeout 90s -race -cover ./... || \
{ echo "--- Rerunning with -v for details ---"; \ { echo "--- Rerunning with -v for details ---"; \
go test -count=1 -timeout 90s -race -v ./...; exit 1; } go test -timeout 90s -race -v ./...; exit 1; }
# Build stage. Nothing is wanted from either phase above; the copies # Build stage. Nothing is wanted from either phase above; the copies
# are what make BuildKit build them first, so this stage cannot run # are what make BuildKit build them first, so this stage cannot run
# unless lint and test passed. # unless lint and test passed.
# golang:1.x-alpine, YYYY-MM-DD # golang:1.x-alpine, YYYY-MM-DD
FROM golang@sha256:... AS builder FROM golang@sha256:... AS builder
ARG CACHEBUST
COPY --from=lint /src/go.sum /dev/null COPY --from=lint /src/go.sum /dev/null
COPY --from=test /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 WORKDIR /src
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY . . COPY . .
# The VERSION build arg when one is given, otherwise ARG VERSION=dev
# `git describe --tags --always` on the .git in the build context. With RUN CGO_ENABLED=0 go build -trimpath \
# .git present, a version that is still empty, dev or unknown fails the -ldflags="-s -w -X main.Version=${VERSION}" \
# build: git is missing or could not read the checkout. -o /app ./cmd/app/
ARG VERSION
RUN --mount=type=cache,target=/root/.cache/go-build \
VERSION="${VERSION:-$(git describe --tags --always)}"; \
if [ -e .git ]; then \
case "$VERSION" in ""|dev|unknown) \
echo "version is '$VERSION' although .git is present" >&2; \
exit 1 ;; \
esac; \
fi; \
CGO_ENABLED=0 go build -trimpath \
-ldflags="-s -w -X main.Version=${VERSION}" \
-o /app ./cmd/app/
# Runtime stage, and the last one # Runtime stage, and the last one
FROM alpine@sha256:... FROM alpine@sha256:...
ARG CACHEBUST
COPY --from=builder /app /usr/local/bin/app COPY --from=builder /app /usr/local/bin/app
ENTRYPOINT ["app"] ENTRYPOINT ["app"]
``` ```
@@ -274,88 +217,23 @@ style conventions are in separate documents:
- Keep the runtime stage last, and if you add a stage after it, give it the - 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 same two copies. A plain `docker build .` builds the last stage's chain
and nothing else. 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`.
- If the project uses `//go:embed` directives that reference build artifacts - 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 (e.g. a web frontend compiled in a separate stage), the lint phase must
create placeholder files so the embed directives resolve. Example: create placeholder files so the embed directives resolve. Example:
`RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css`. `RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css`.
- If the project requires CGO or system libraries for linting, install them - If the project requires CGO or system libraries for linting (e.g.
in the lint phase. The `golangci/golangci-lint` image is Debian-based and `vips-dev`), install them in the lint phase with `apk add`.
has no `apk`, so install with `apt-get` under the Debian package name - `ARG VERSION=dev` is declared in the stage that compiles and supplied by
(`libvips-dev`, where alpine says `vips-dev`), and delete the package `script/docker` and `script/cibuild`; no stage may call `git describe`.
lists in the same `RUN`, so the layer does not keep them:
```dockerfile
RUN apt-get update \
&& apt-get install -y --no-install-recommends libvips-dev \
&& rm -rf /var/lib/apt/lists/*
```
- `.dockerignore` lets `.git` into the build context. It keeps out every git
`config` at any depth (`**/.git/config`, `**/.git/modules/**/config`): the
repository's own, each submodule's under `.git/modules/`, and that of a
submodule keeping its own `.git` directory. `git describe` does not need
them, and each can hold a credential: a password in a remote URL, or the
token the CI checkout step stores there. A submodule whose name has a
`config` segment (`config`, `deploy/config`, `config/lib`) loses its whole
git directory to `**/.git/modules/**/config`, and Go's version stamping
then fails the build: give it a name without that segment
(`git submodule add --name`). The stage that compiles has `git` (the
Debian Go image has it; an alpine one needs `apk add --no-cache git`) and
takes the version from the `VERSION` build argument when one is given,
otherwise from `git describe --tags --always`. That gives the tag on a
tagged commit; on a later commit, the tag, the number of commits since it
and the short commit (`v1.2.3-4-gabc1234`); and the short commit when no
tag is reachable. The stage that compiles also marks its working directory
safe for git (`git config --system --add safe.directory /src`): a context
sent as a tar stream keeps the sender's file owners, and git refuses a
checkout owned by another user, so the version would come out empty.
`ARG VERSION` has no default, and the build fails if the context carries
`.git` and the version still comes out empty, `dev` or `unknown`. A plain
`docker build .` with no build arguments must succeed; a Dockerfile that
refuses an empty build argument drops that refusal and keeps the argument.
A checkout whose `.git` is a file (a linked worktree, or a repository
checked out as a submodule) is the exception: that file points to a git
directory outside the build context, so the build cannot read the version
and a plain `docker build .` fails; pass the version with
`--build-arg VERSION=...`, as `script/docker` and `script/cibuild` already
do.
- Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that - Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that
runs `script/cibuild` on push, and checks out the repo as its only other step, runs `script/cibuild` on push, and checks out the repo as its only other step.
with `persist-credentials: false`: `script/cibuild` needs no token, and That script bootstraps, runs the gate phases, and then builds the image, so a
without it the checkout leaves the job's token in `.git/config` for every successful run means every check passed; a bare `docker build .` does not
later step. The checkout step also sets `fetch-depth: 0`, which fetches the
tags `git describe` needs: by default it clones shallow with no tags, and a
tagged repository's CI build would stamp a bare short commit id. The
workflow's `concurrency` block groups runs by workflow and branch
(`${{ github.workflow }}-${{ github.ref }}`) with `cancel-in-progress: true`,
so a new push cancels the older run on the same branch, queued or running, and
no other: runs for replaced commits do not hold up the shared runner.
`script/cibuild` bootstraps, runs the gate phases, and then builds the image,
so a successful run means every check passed; a bare `docker build .` does not
carry the same guarantee, because its gate phases may come from the cache. The carry the same guarantee, because its gate phases may come from the cache. The
image build gets a `CACHEBUST` value of its own and so runs the gate phases a image build is uncached and so runs the gate phases a second time. That is the
second time. That is the price of the rule above, and it is worth paying: the price of the rule above, and it is worth paying: the image that ships is built
image that ships is built from a run of its own gates rather than from a cache from a run of its own gates rather than from a cache entry.
entry. The `check` job sets `timeout-minutes: 20`, so a hung build frees the
shared runner after 20 minutes. That allows for the three Docker builds
described above (the test phase, the lint phase, then the image), each held to
the 5-minute Docker build limit below, plus the bootstrap. A separate workflow
limited to `main` by a `branches` list under `on: push` cannot be checked by
review: to try a change to it, add the feature branch to that list and push,
then remove the branch from the list again before merging. Keep any job in it
that publishes behind `if: github.ref_name == 'main'`, so the run from the
feature branch publishes nothing.
- Use platform-standard formatters: `black` for Python, `prettier` for - Use platform-standard formatters: `black` for Python, `prettier` for
JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with
@@ -408,18 +286,17 @@ style conventions are in separate documents:
``` ```
`-count=1` is required on both invocations: it defeats Go's test _result_ `-count=1` is required on both invocations: it defeats Go's test _result_
cache, so neither run can report a stored pass in place of running the cache, so the target cannot report a pass it did not earn, and the rerun
tests. It leaves the build cache alone, so it costs the runtime of the suite reproduces a failure instead of replaying it. It leaves the build cache
and no recompilation. alone, so it costs the runtime of the suite and no recompilation.
That cache is Go's own, separate from Docker's layer cache. Go stores a Note that this is a second, independent cache, stacked below the Docker
passing result in its cache directory (`GOCACHE`), and when the same tests layer cache that [issue #26](https://git.eeqj.de/sneak/prompts/issues/26)
run again on unchanged code it prints that result, marked `(cached)`, addresses. `CHECK_EPOCH` guarantees the `RUN make test` _step_ re-executes;
without running them. The directory lasts from one run to the next on a it does not guarantee `go test` inside that step does any work, because the
developer's machine, where this target runs, and in the `test` phase of the `GOCACHE` baked into earlier image layers survives into the re-executed
`Dockerfile`, which mounts it to keep compiled packages between check step. They are two separate defects requiring two separate fixes, and a fix
builds, so the phase's `go test` runs carry `-count=1` too. A new for one must not be recorded as covering the other.
`CACHEBUST` value (above) is what makes that step run on an unchanged tree.
Python example: Python example:
@@ -447,12 +324,9 @@ style conventions are in separate documents:
- `.gitignore` should be comprehensive from the start: OS files (`.DS_Store`), - `.gitignore` should be comprehensive from the start: OS files (`.DS_Store`),
editor files (`.swp`, `*~`), in-repo agent scratch directories (`.claude/`), editor files (`.swp`, `*~`), in-repo agent scratch directories (`.claude/`),
`node_modules/`, and the repo's own build outputs. Fetch the standard language build artifacts, and `node_modules/`. Fetch the standard `.gitignore`
`.gitignore` from from `https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when setting up setting up a new repo. These patterns are written to `.gitignore`'s own
a new repo. A repo's `.gitignore` is the standard file followed by the repo's
own entries, such as its binaries; a re-vendor replaces the standard part and
keeps those entries. These patterns are written to `.gitignore`'s own
semantics, in which an unanchored pattern already matches at every depth; they semantics, in which an unanchored pattern already matches at every depth; they
are not a `.dockerignore` and must not be transplanted into one unmodified. are not a `.dockerignore` and must not be transplanted into one unmodified.
@@ -466,7 +340,7 @@ style conventions are in separate documents:
— which is more dangerous than a short file with no secret patterns at all, — which is more dangerous than a short file with no secret patterns at all,
because it reads as solved and stops anyone looking. Give every because it reads as solved and stops anyone looking. Give every
depth-independent pattern the `**/` prefix and leave only genuinely depth-independent pattern the `**/` prefix and leave only genuinely
root-anchored entries unprefixed: `.claude`, and the repo's own host-built root-anchored entries unprefixed: `.git`, and the repo's own host-built
binary, written `/myapp` and never `**/myapp`, which would also match binary, written `/myapp` and never `**/myapp`, which would also match
`cmd/myapp/` and delete the package directory from the context. Matching is `cmd/myapp/` and delete the package directory from the context. Matching is
case-sensitive, and an ALL-CAPS twin per pattern still misses `Server.Key`, so case-sensitive, and an ALL-CAPS twin per pattern still misses `Server.Key`, so
@@ -491,24 +365,21 @@ style conventions are in separate documents:
directory, so a repo running agents in subdirectories still ships directory, so a repo running agents in subdirectories still ships
`services/api/.claude/` and must add its own anchored entry there. `services/api/.claude/` and must add its own anchored entry there.
- **A plain `docker build .` of a clone stamps the version that - **Excluding `.git` means `git describe` cannot run inside any build stage, and
`git describe --tags --always` gives**, derived from the `.git` in the build it fails quietly there.** In a build stage there is no repository, so
context as the canonical `Dockerfile` above shows. Without its failure check, `git describe` writes nothing to stdout, `-X main.Version=` comes out empty,
a missing `git` or an unreadable checkout would leave `-X main.Version=` empty the binary reports no version at all, and the build still exits 0. Compute the
and the build would still exit 0. `script/docker` and `script/cibuild` pass version on the host and thread it in as a build arg. `script/docker` and
the version they compute on the host; it takes precedence. They do this `script/cibuild` do this, byte-identically across repos:
byte-identically across repos:
```sh ```sh
# The version and the tag each get their own line: a failing command # Own line: a failing command substitution inside an argument does not
# substitution inside an argument does not trip `set -e`, so the inline # trip `set -e`, so the inline form degrades to an empty constant.
# form degrades to an empty constant.
version="$(git describe --tags --always --dirty 2>/dev/null || true)" version="$(git describe --tags --always --dirty 2>/dev/null || true)"
[ -n "$version" ] || version="unknown" [ -n "$version" ] || version="unknown"
tag="$(script/projectname)" docker build --no-cache \
docker build --build-arg CACHEBUST="$(date +%s)-$$" \
--build-arg VERSION="$version" \ --build-arg VERSION="$version" \
-t "$tag" . -t "$(script/projectname)" .
``` ```
`--always` makes an untagged repo yield an abbreviated commit hash rather `--always` makes an untagged repo yield an abbreviated commit hash rather
@@ -516,12 +387,12 @@ style conventions are in separate documents:
fallback is applied — a live check that fires on a build from an export with fallback is applied — a live check that fires on a build from an export with
no `.git` and on a repository with no commits yet. Do not fold it into the no `.git` and on a repository with no commits yet. Do not fold it into the
substitution as `|| echo unknown`, which makes the guard unreachable. The substitution as `|| echo unknown`, which makes the guard unreachable. The
Dockerfile's side is `ARG VERSION` in the stage that compiles, declared Dockerfile's side is `ARG VERSION=dev` in the stage that compiles, declared
there because `ARG` is stage-scoped; passing `VERSION` to a repo whose there because `ARG` is stage-scoped; passing `VERSION` to a repo whose
Dockerfile declares no such `ARG` is ignored and costs nothing, which is why Dockerfile declares no such `ARG` is ignored and costs nothing, which is why
the scripts stay byte-identical. One consequence for CI: the standard the scripts stay byte-identical. One consequence for CI: the standard
checkout action clones shallow and fetches no tags, so the canonical checkout action clones shallow and fetches no tags, so a repo that embeds a
`.gitea/workflows/check.yml` sets `fetch-depth: 0` on its checkout step. tag-derived version must set `fetch-depth: 0` on its checkout step.
- **Verify `.dockerignore` by enumerating the image, not by reading the - **Verify `.dockerignore` by enumerating the image, not by reading the
patterns.** Plant files at the root _and_ at least two directories deep, build patterns.** Plant files at the root _and_ at least two directories deep, build
@@ -555,18 +426,12 @@ style conventions are in separate documents:
`test-support` depguard rule, where a repo names its own test-support packages `test-support` depguard rule, where a repo names its own test-support packages
by full import path. A repo adds entries there and changes nothing else, and a by full import path. A repo adds entries there and changes nothing else, and a
re-vendor carries its entries forward. The canonical golangci-lint version is re-vendor carries its entries forward. The canonical golangci-lint version is
v2.14.0 (released 2026-09-24), pinned as the digest of the lint phase's base v2.12.2 (released 2026-05-06), pinned as the digest of the lint phase's base
image image
(`golangci/golangci-lint@sha256:ad862ba6b3798cbe0fd9fd7408d498fd74fbd2623a92406b2fd3898faf0bf98f`, (`golangci/golangci-lint@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240`,
which reports `2.14.0 built with go1.27.0 from 114493f9`). A module's `go` which reports `2.12.2 built with go1.26.2 from c0d3ddc9`). That digest is the
directive must not name a newer Go minor version than the one golangci-lint only pin, since no repo installs golangci-lint on the host: bumping the
was built with, or golangci-lint refuses to lint it: this release lints version means changing it and nothing else.
`go 1.27.1` but not `go 1.28`. That digest is the only pin, since no repo
installs golangci-lint on the host. A repo sets the lint phase digest to the
one named here and re-vendors `.golangci.yml` in the same commit, whichever of
the two prompted the change: the canonical copy can name linters that an older
golangci-lint rejects, and a newer golangci-lint can add linters that
`default: all` switches on until the canonical copy disables them.
- **`script/bootstrap` installs a pinned tool by comparing versions, never by - **`script/bootstrap` installs a pinned tool by comparing versions, never by
testing presence.** An `if ! command -v <tool>; then install; fi` guard tests testing presence.** An `if ! command -v <tool>; then install; fi` guard tests
@@ -588,18 +453,8 @@ style conventions are in separate documents:
function defined and never invoked has the same exit status and the same function defined and never invoked has the same exit status and the same
empty output as one that worked. empty output as one that worked.
Node is the exception to the whole-token comparison: the canonical
`script/bootstrap` compares only its major version, because the node 22
alpine image the `Dockerfile` stages start from is not the exact pin, and
nvm cannot install a prebuilt node on alpine.
Keep it POSIX sh: no arrays, no `[[`, no `grep -P`. Keep it POSIX sh: no arrays, no `[[`, no `grep -P`.
A Go tool a repo needs on the host is installed with `go install` pinned to
a commit hash (`go install <package>@<commit hash>`). It is never tracked as
a `go.mod` tool dependency or through a `tools.go` file, either of which
pulls the tool's own dependencies into the repo's `go.mod` and `go.sum`.
- When pinning images or packages by hash, add a comment above the reference - When pinning images or packages by hash, add a comment above the reference
with the version and date (YYYY-MM-DD). with the version and date (YYYY-MM-DD).
@@ -709,17 +564,13 @@ style conventions are in separate documents:
Never edit existing migrations after release. Never edit existing migrations after release.
- All repos should have an `.editorconfig` enforcing the project's indentation - All repos should have an `.editorconfig` enforcing the project's indentation
settings: the standard file from settings.
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.editorconfig`, which sets
tabs for `Makefile` and Go files, followed by the repo's own sections, such as
one for another language it uses. A re-vendor replaces the standard part and
keeps those sections.
- Avoid putting files in the repo root unless necessary. Root should contain - Avoid putting files in the repo root unless necessary. Root should contain
only project-level config files (`README.md`, `AGENTS.md`, `Makefile`, only project-level config files (`README.md`, `Makefile`, `Dockerfile`,
`Dockerfile`, `LICENSE`, `.gitignore`, `.editorconfig`, `REPO_POLICIES.md`, `LICENSE`, `.gitignore`, `.editorconfig`, `REPO_POLICIES.md`, and
and language-specific config). Everything else goes in a subdirectory. language-specific config). Everything else goes in a subdirectory. Canonical
Canonical subdirectory names: subdirectory names:
- `bin/` — executable scripts and tools - `bin/` — executable scripts and tools
- `cmd/` — Go command entrypoints; thin only: one `main.go` per binary whose - `cmd/` — Go command entrypoints; thin only: one `main.go` per binary whose
body is a single call into `internal/` or `pkg/`, no project logic in body is a single call into `internal/` or `pkg/`, no project logic in
@@ -750,7 +601,3 @@ style conventions are in separate documents:
- Go: `go.mod`, `go.sum`, `.golangci.yml` - Go: `go.mod`, `go.sum`, `.golangci.yml`
- JS: `package.json`, `yarn.lock`, `.prettierrc`, `.prettierignore` - JS: `package.json`, `yarn.lock`, `.prettierrc`, `.prettierignore`
- Python: `pyproject.toml` - Python: `pyproject.toml`
- Guidance for coding agents lives in one `AGENTS.md` at the repository root. It
is never committed under a file or directory named after one agent tool, such
as `CLAUDE.md` or `.claude/`, and never split into separate memory files.
+8 -38
View File
@@ -18,7 +18,7 @@
pre-1.0. No git tags exist. The `1.0.0` milestone is in progress; work lands on pre-1.0. No git tags exist. The `1.0.0` milestone is in progress; work lands on
`next`, and `main` receives only the milestone PR that the owner merges. `next` `next`, and `main` receives only the milestone PR that the owner merges. `next`
is at the canonical `golangci-lint` v2.14.0 config and is green. Recent work is at the canonical `golangci-lint` v2.12.2 config and is green. Recent work
extracted the internal/magic, internal/allowlist, internal/httpfetcher, and extracted the internal/magic, internal/allowlist, internal/httpfetcher, and
internal/signature packages. The gosec findings from the 2026-07-06 survey are internal/signature packages. The gosec findings from the 2026-07-06 survey are
resolved. The disk cache is now size-bounded with LRU eviction resolved. The disk cache is now size-bounded with LRU eviction
@@ -30,36 +30,6 @@ P2: security: per-origin rate limiting
# Completed Steps # Completed Steps
- 2026-10-15 the reconciliation pass runs at startup and then once a day (closes
#250): it ran on every eviction tick, every 5 minutes in pixad, and makes a
database query for every cached file, on the one connection requests also use.
On a large cache a pass could take longer than 5 minutes, and the next one
then started at once, so the passes never stopped. The next pass now starts a
day after the previous one ends, so passes never run back to back. Eviction
and the removal of expired negative cache rows keep their 5-minute tick, and
eviction also runs after each reconciliation pass. A variant file whose
database row failed to be written is now counted in the cache size up to about
a day later, rather than 5 minutes.
- 2026-10-15 the canonical files are re-vendored from `sneak/prompts` commit
`e3f79c4` (closes #221): `.dockerignore`, `.editorconfig`,
`.gitea/workflows/check.yml`, `.gitignore`, `.golangci.yml`,
`.prettierignore`, `.prettierrc`, `REPO_POLICIES.md` and the standard scripts
are its copies. pixa's own entries follow the standard part: `/bin`, `/data`
and the local config files in `.dockerignore`; Go's `*.log`, `*.out` and
`*.test`, `vendor/`, the binaries, `/data/`, `*.sqlite3` and the local config
files in `.gitignore`. The workflow keeps its `script/docker-smoke` step while
https://git.eeqj.de/sneak/pixa/issues/203 is open. The scripts pass a new
`CACHEBUST` build argument in place of `--no-cache`, and every `Dockerfile`
stage declares it first, so every `RUN` step still runs on every build while
Go's build cache, mounted on the steps that compile, is kept; both `go test`
runs keep `-count=1`. The lint and test builds write no image. The lint phase
runs golangci-lint v2.14.0, the test phase runs on the Debian Go image, and
the build stage marks `/src` safe for git. `script/bootstrap`, `script/fmt`
and `script/fmt-check` use the node on the `PATH` only when it has the pinned
major version. Of golangci-lint v2.14.0's two findings, an unused `nolint`
directive is removed, and gosec's new path check is marked as not applying to
the fixed config file locations. The agent guidance moves unchanged from
`CLAUDE.md` to a root `AGENTS.md`, where `REPO_POLICIES.md` keeps it.
- 2026-10-15 the request metrics record any method that is not a standard one as - 2026-10-15 the request metrics record any method that is not a standard one as
`other` (closes #274): their `method` label was each request's method as sent, `other` (closes #274): their `method` label was each request's method as sent,
and Go's HTTP server accepts any method name, so a client sending made-up and Go's HTTP server accepts any method name, so a client sending made-up
@@ -91,13 +61,13 @@ P2: security: per-origin rate limiting
each transformed image's `.meta` file, are synced to disk before they are each transformed image's `.meta` file, are synced to disk before they are
renamed into place, and their directory after; the `.meta` file is written renamed into place, and their directory after; the `.meta` file is written
before the image, and a failed directory sync removes the renamed file. The before the image, and a failed directory sync removes the renamed file. The
reconciliation pass, at startup and once a day, removes a cached source or reconciliation pass, at startup and every five minutes, removes a cached
transformed image whose file is empty, or not the size the database records source or transformed image whose file is empty, or not the size the database
for it, with its rows and a warning naming the file, so the next request records for it, with its rows and a warning naming the file, so the next
fetches the source or converts the image again; requests compare no sizes. request fetches the source or converts the image again; requests compare no
Storing a source over a file of another size replaces that file. A source that sizes. Storing a source over a file of another size replaces that file. A
fails to decode is not removed for that alone: a damaged upstream image would source that fails to decode is not removed for that alone: a damaged upstream
then be fetched on every request. image would then be fetched on every request.
- 2026-10-15 a transcode stops once its request's `downstream_timeout` has - 2026-10-15 a transcode stops once its request's `downstream_timeout` has
ended, unless it has begun encoding (closes #243): the image processor looked ended, unless it has begun encoding (closes #243): the image processor looked
at its context only while it waited for a processing slot, so an image was at its context only while it waited for a processing slot, so an image was
-1
View File
@@ -823,7 +823,6 @@ func loadConfigFile(log *slog.Logger, appName string) (*smartconfig.Config, erro
// one whose path runs through a file, such as under a HOME of // one whose path runs through a file, such as under a HOME of
// /dev/null. One that cannot be read or does not parse is a // /dev/null. One that cannot be read or does not parse is a
// fatal startup error. // fatal startup error.
//nolint:gosec // G703: a fixed config location; HOME is the operator's
_, statErr := os.Stat(cleanPath) _, statErr := os.Stat(cleanPath)
if errors.Is(statErr, fs.ErrNotExist) || errors.Is(statErr, syscall.ENOTDIR) { if errors.Is(statErr, fs.ErrNotExist) || errors.Is(statErr, syscall.ENOTDIR) {
continue continue
+4
View File
@@ -78,6 +78,10 @@ func (c *Cache) computeDefaultMaxBytes(
computed := spaceBytes / freeSpaceFractionDenominator * freeSpaceFractionNumerator computed := spaceBytes / freeSpaceFractionDenominator * freeSpaceFractionNumerator
computed = min(computed, math.MaxInt64) computed = min(computed, math.MaxInt64)
// gosec cannot see that min() above bounds computed, so it reads
// this conversion as potentially overflowing. It cannot: computed is
// at most math.MaxInt64 on every path here.
//nolint:gosec // G115: clamped to MaxInt64 by min above
limit := int64(computed) limit := int64(computed)
limit = max(limit, DefaultCacheMaxBytesFloor) limit = max(limit, DefaultCacheMaxBytesFloor)
+32 -41
View File
@@ -18,11 +18,6 @@ import (
// write-pressure wakeups triggered by stores. // write-pressure wakeups triggered by stores.
const DefaultEvictionInterval = 5 * time.Minute const DefaultEvictionInterval = 5 * time.Minute
// reconciliationInterval is how long after one reconciliation pass ends
// the next one starts. A pass reads a row for every cached file, so its
// cost grows with the cache, and it runs this rarely for that reason.
const reconciliationInterval = 24 * time.Hour
// evictionBatchSize is how many LRU candidates of each class (variants // evictionBatchSize is how many LRU candidates of each class (variants
// and source blobs) one eviction pass fetches from the database. // and source blobs) one eviction pass fetches from the database.
const evictionBatchSize = 100 const evictionBatchSize = 100
@@ -442,17 +437,15 @@ func (c *Cache) notifyWritePressure() {
} }
} }
// StartEviction launches the background eviction goroutine. At startup // StartEviction launches the background eviction goroutine, which
// it reconciles the database accounting with the cache directories, // reconciles the database accounting with the cache directories and
// removes the expired negative cache rows and evicts to the configured // removes the expired negative cache rows at startup and again on every
// limit. After that it reconciles again a day after each reconciliation // periodic tick thereafter, and evicts to the configured limit on the
// pass ends, removes the expired negative cache rows on every tick of // given periodic interval and on write-pressure notifications. On a
// the given interval, and evicts to the limit on every tick, after each // disabled cache it only removes the expired negative cache rows, as
// reconciliation pass and on write-pressure notifications. On a disabled // the negative cache is used either way. It is a no-op when already
// cache it only removes the expired negative cache rows, as the negative // started. The goroutine outlives the caller, so it runs with its own
// cache is used either way. It is a no-op when already started. The // context, which StopEviction cancels.
// goroutine outlives the caller, so it runs with its own context, which
// StopEviction cancels.
func (c *Cache) StartEviction(interval time.Duration) { func (c *Cache) StartEviction(interval time.Duration) {
if c.evictionCancel != nil { if c.evictionCancel != nil {
return return
@@ -493,16 +486,6 @@ func (c *Cache) evictionLoop(ctx context.Context, interval time.Duration) {
c.runCleanExpired(ctx) c.runCleanExpired(ctx)
c.runEvictionPass(ctx) c.runEvictionPass(ctx)
// Reconciliation walks the cache directories and reads a row for
// every file, so it runs on a timer of its own rather than on the
// periodic tick or on write-pressure wakeups. The timer starts only
// once a pass has ended, so passes never run back to back, however
// long one takes. Running it daily, not just at startup, bounds how
// long a store's best-effort accounting insert can stay silently
// unaccounted for to about one day, plus the time the passes take.
reconciliationTimer := time.NewTimer(reconciliationInterval)
defer reconciliationTimer.Stop()
ticker := time.NewTicker(interval) ticker := time.NewTicker(interval)
defer ticker.Stop() defer ticker.Stop()
@@ -510,10 +493,17 @@ func (c *Cache) evictionLoop(ctx context.Context, interval time.Duration) {
select { select {
case <-ctx.Done(): case <-ctx.Done():
return return
case <-reconciliationTimer.C:
c.runReconciliationPass(ctx)
reconciliationTimer.Reset(reconciliationInterval)
case <-ticker.C: case <-ticker.C:
// Reconciliation walks the cache directories, so it only
// runs on the periodic ticker rather than on every
// write-pressure wakeup, keeping it off the per-store hot
// path. Reusing the eviction interval itself (rather than a
// separate, longer one) is a deliberate choice: it is the
// simplest option that still bounds how long a store's
// best-effort accounting insert can stay silently
// unaccounted for to one interval, on a process that is
// already running this loop regardless.
c.runReconciliationPass(ctx)
c.runCleanExpired(ctx) c.runCleanExpired(ctx)
case <-c.evictionPressure: case <-c.evictionPressure:
} }
@@ -566,18 +556,19 @@ func (c *Cache) runCleanExpired(ctx context.Context) {
// reconcileAccounting synchronizes the database size accounting with // reconcileAccounting synchronizes the database size accounting with
// the actual contents of the cache directories. It runs at startup and // the actual contents of the cache directories. It runs at startup and
// then once a day, off the request hot path: it adopts variant files // again on every periodic eviction tick thereafter, off the request
// that predate the accounting table (or whose accounting insert failed, // hot path: it adopts variant files that predate the accounting table
// e.g. StoreVariant's best-effort insert under transient DB contention), // (or whose accounting insert failed, e.g. StoreVariant's best-effort
// drops accounting rows whose files are missing, removes source blob // insert under transient DB contention), drops accounting rows whose
// files the database does not know (and rows whose files are gone), // files are missing, removes source blob files the database does not
// removes source and variant files that are empty or not the size their // know (and rows whose files are gone), removes source and variant files
// rows record, sweeps stale temp files left behind by crashed writes, // that are empty or not the size their rows record, sweeps stale temp
// and last checks the total cache usage against the tables. It reads the // files left behind by crashed writes, and last checks the total cache
// tables a page at a time. Running it daily, not just once, bounds how // usage against the tables. It reads the tables a page at a time.
// long such drift can accumulate unaccounted for on a long-running // Running it periodically, not just once, bounds how long such drift can
// process to about one day. Once ctx is cancelled, it stops at the next // accumulate unaccounted for on a long-running process to one eviction
// file or row and returns ctx's error. // interval. Once ctx is cancelled, it stops at the next file or row and
// returns ctx's error.
func (c *Cache) reconcileAccounting(ctx context.Context) error { func (c *Cache) reconcileAccounting(ctx context.Context) error {
if c.disabled { if c.disabled {
return nil return nil
+12 -33
View File
@@ -3,13 +3,11 @@
# this repo. Idempotent: every install is guarded by a check so already # this repo. Idempotent: every install is guarded by a check so already
# installed tools are skipped. Base tooling comes from nix, apt, brew, # installed tools are skipped. Base tooling comes from nix, apt, brew,
# or apk (detected in that order); assumes NOTHING is present (not git, # or apk (detected in that order); assumes NOTHING is present (not git,
# make, or go). The installed node is used only when its major version # make, or go). Node is used directly if installed; otherwise it is
# is the pinned one; otherwise (node missing, or another major) the # installed at a pinned version via nvm (installing nvm itself first,
# pinned version is installed via nvm (installing nvm itself first, # from a hash-verified release archive, never curl | sh). The linter is
# from a hash-verified release archive, never curl | sh), and yarn and # never installed on the host: golangci-lint runs only in the lint phase
# the packages are installed under it. The linter is never installed on # of the Dockerfile (see script/lint).
# the host: golangci-lint runs only in the lint phase of the Dockerfile
# (see script/lint).
# #
# script/bootstrap git, make, Go, and Node, Yarn and the # script/bootstrap git, make, Go, and Node, Yarn and the
# prettier in yarn.lock for script/fmt and # prettier in yarn.lock for script/fmt and
@@ -112,50 +110,31 @@ ensure_nvm() {
rm -rf "$tmp" rm -rf "$tmp"
} }
# True when the node on PATH has the pinned major version. Only the
# major is compared, as REPO_POLICIES.md asks. Another major is not
# used: the pinned yarn 1 prints a deprecation warning under node 24.
# script/fmt and script/fmt-check carry this function unchanged, to run
# the yarn installed here.
node_is_pinned_major() {
if ! command -v node >/dev/null 2>&1; then return 1; fi
installed="$(node --version)"
installed="${installed#v}"
[ "${installed%%.*}" = "${NODE_VERSION%%.*}" ]
}
ensure_node() { ensure_node() {
if node_is_pinned_major; then if ! missing node; then return 0; fi
echo "bootstrap: using node $(node --version)"
return 0
fi
ensure_nvm ensure_nvm
nvm_sh "nvm install $NODE_VERSION" nvm_sh "nvm install $NODE_VERSION"
nvm_sh "nvm use $NODE_VERSION >/dev/null && \
echo \"bootstrap: using node \$(node --version)\""
} }
ensure_yarn() { ensure_yarn() {
if ! node_is_pinned_major; then
nvm_sh "nvm use $NODE_VERSION >/dev/null && corepack enable && \
corepack prepare yarn@$YARN_VERSION --activate"
return 0
fi
if ! missing yarn; then return 0; fi if ! missing yarn; then return 0; fi
if ! missing corepack; then if ! missing corepack; then
corepack enable corepack enable
corepack prepare "yarn@$YARN_VERSION" --activate corepack prepare "yarn@$YARN_VERSION" --activate
elif [ -s "$HOME/.nvm/nvm.sh" ]; then
nvm_sh "nvm use $NODE_VERSION >/dev/null && corepack enable && \
corepack prepare yarn@$YARN_VERSION --activate"
else else
npm install -g "yarn@$YARN_VERSION" npm install -g "yarn@$YARN_VERSION"
fi fi
} }
install_js_deps() { install_js_deps() {
if node_is_pinned_major; then if missing yarn && [ -s "$HOME/.nvm/nvm.sh" ]; then
yarn install --frozen-lockfile
else
nvm_sh "nvm use $NODE_VERSION >/dev/null && cd \"$ROOT\" && \ nvm_sh "nvm use $NODE_VERSION >/dev/null && cd \"$ROOT\" && \
yarn install --frozen-lockfile" yarn install --frozen-lockfile"
else
yarn install --frozen-lockfile
fi fi
} }
+10 -13
View File
@@ -2,11 +2,9 @@
# script/cibuild: run the CI build. It bootstraps first: a CI runner # 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 # checks out and runs this and nothing else, and script/fmt-check runs
# the formatter on the host, which a pristine checkout cannot do. # the formatter on the host, which a pristine checkout cannot do.
# A new CACHEBUST value reruns every RUN step, since a cached gate phase # --no-cache for the same reason as script/docker: the gate phases the
# is a check that did not run; unlike --no-cache, it keeps Go's build cache. # final stage depends on are RUN steps, and a cached one is a check that
# This relies on every stage of the Dockerfile declaring ARG CACHEBUST # did not run.
# first; a stage without that line is served from cache on an unchanged
# tree, with no warning.
set -eu set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
@@ -16,17 +14,16 @@ main() {
cd "$ROOT" cd "$ROOT"
"$SCRIPT_DIR/bootstrap" "$SCRIPT_DIR/bootstrap"
"$SCRIPT_DIR/check" "$SCRIPT_DIR/check"
# The version and the tag each get their own line: a failing # Own line: a failing command substitution inside an argument does
# command substitution inside an argument does not trip `set -e`, # not trip `set -e`, so the inline form degrades silently to an
# so the inline form degrades silently to an empty constant. The # empty constant. VERSION is computed here because .dockerignore
# VERSION build argument takes precedence over the version a build # excludes .git, so `git describe` in a build stage yields an empty
# stage derives from the .git in the context. # version without failing.
version="$(git describe --tags --always --dirty 2>/dev/null || true)" version="$(git describe --tags --always --dirty 2>/dev/null || true)"
[ -n "$version" ] || version="unknown" [ -n "$version" ] || version="unknown"
tag="$("$SCRIPT_DIR/projectname")" docker build --no-cache \
docker build --build-arg CACHEBUST="$(date +%s)-$$" \
--build-arg VERSION="$version" \ --build-arg VERSION="$version" \
-t "$tag" . -t "$("$SCRIPT_DIR/projectname")" .
} }
main "$@" main "$@"
+9 -13
View File
@@ -1,11 +1,8 @@
#!/bin/sh #!/bin/sh
# script/docker: build the Docker image tagged with the project name. # script/docker: build the Docker image tagged with the project name.
# Identical in all repos; the tag comes from script/projectname. # Identical in all repos; the tag comes from script/projectname.
# A new CACHEBUST value reruns every RUN step, since a cached gate phase # --no-cache because the gate phases the final stage depends on are RUN
# is a check that did not run; unlike --no-cache, it keeps Go's build cache. # steps, and a cached one is a check that did not run.
# 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.
set -eu set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
@@ -13,17 +10,16 @@ ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() { main() {
cd "$ROOT" cd "$ROOT"
# The version and the tag each get their own line: a failing # Own line: a failing command substitution inside an argument does
# command substitution inside an argument does not trip `set -e`, # not trip `set -e`, so the inline form degrades silently to an
# so the inline form degrades silently to an empty constant. The # empty constant. VERSION is computed here because .dockerignore
# VERSION build argument takes precedence over the version a build # excludes .git, so `git describe` in a build stage yields an empty
# stage derives from the .git in the context. # version without failing.
version="$(git describe --tags --always --dirty 2>/dev/null || true)" version="$(git describe --tags --always --dirty 2>/dev/null || true)"
[ -n "$version" ] || version="unknown" [ -n "$version" ] || version="unknown"
tag="$("$SCRIPT_DIR/projectname")" docker build --no-cache \
docker build --build-arg CACHEBUST="$(date +%s)-$$" \
--build-arg VERSION="$version" \ --build-arg VERSION="$version" \
-t "$tag" . -t "$("$SCRIPT_DIR/projectname")" .
} }
main "$@" main "$@"
+6 -16
View File
@@ -7,25 +7,15 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
# Must match the pin in script/bootstrap. # Must match the pin in script/bootstrap.
NODE_VERSION="22.17.0" NODE_VERSION="22.17.0"
# True when the node on PATH has the pinned major version: the same # script/bootstrap installs node and yarn under nvm and leaves neither
# function as in script/bootstrap, which uses it to decide where it # on the PATH of the shell that called it, so resolve the pinned
# installs yarn. # toolchain here the way bootstrap's own install step does. nvm is a
node_is_pinned_major() { # bash script, hence the subshell.
if ! command -v node >/dev/null 2>&1; then return 1; fi
installed="$(node --version)"
installed="${installed#v}"
[ "${installed%%.*}" = "${NODE_VERSION%%.*}" ]
}
# Run the yarn script/bootstrap installed: the yarn on PATH when the
# node on PATH has the pinned major version, and otherwise yarn under
# the pinned node in nvm, which bootstrap leaves off the PATH of the
# shell that called it. nvm is a bash script, hence the subshell.
run_yarn() { run_yarn() {
if node_is_pinned_major && command -v yarn >/dev/null 2>&1; then if command -v yarn >/dev/null 2>&1; then
exec yarn "$@" exec yarn "$@"
fi fi
if node_is_pinned_major || [ ! -s "$HOME/.nvm/nvm.sh" ]; then if [ ! -s "$HOME/.nvm/nvm.sh" ]; then
echo "fmt: no yarn; run script/bootstrap first" >&2 echo "fmt: no yarn; run script/bootstrap first" >&2
exit 1 exit 1
fi fi
+6 -16
View File
@@ -8,25 +8,15 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
# Must match the pin in script/bootstrap. # Must match the pin in script/bootstrap.
NODE_VERSION="22.17.0" NODE_VERSION="22.17.0"
# True when the node on PATH has the pinned major version: the same # script/bootstrap installs node and yarn under nvm and leaves neither
# function as in script/bootstrap, which uses it to decide where it # on the PATH of the shell that called it, so resolve the pinned
# installs yarn. # toolchain here the way bootstrap's own install step does. nvm is a
node_is_pinned_major() { # bash script, hence the subshell.
if ! command -v node >/dev/null 2>&1; then return 1; fi
installed="$(node --version)"
installed="${installed#v}"
[ "${installed%%.*}" = "${NODE_VERSION%%.*}" ]
}
# Run the yarn script/bootstrap installed: the yarn on PATH when the
# node on PATH has the pinned major version, and otherwise yarn under
# the pinned node in nvm, which bootstrap leaves off the PATH of the
# shell that called it. nvm is a bash script, hence the subshell.
run_yarn() { run_yarn() {
if node_is_pinned_major && command -v yarn >/dev/null 2>&1; then if command -v yarn >/dev/null 2>&1; then
exec yarn "$@" exec yarn "$@"
fi fi
if node_is_pinned_major || [ ! -s "$HOME/.nvm/nvm.sh" ]; then if [ ! -s "$HOME/.nvm/nvm.sh" ]; then
echo "fmt-check: no yarn; run script/bootstrap first" >&2 echo "fmt-check: no yarn; run script/bootstrap first" >&2
exit 1 exit 1
fi fi
+5 -8
View File
@@ -5,12 +5,9 @@
# make its answer untrustworthy. # make its answer untrustworthy.
# #
# The phase is not the last stage in the file, so it is built only when # 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 # --target names it. --no-cache because a cached lint layer is a lint
# a cached lint layer is a lint that did not run; unlike --no-cache, it # that did not run. The tag makes each build replace the previous image
# keeps Go's build cache. This relies on every stage of the Dockerfile # instead of leaving a dangling one behind.
# 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.
set -eu set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
@@ -18,9 +15,9 @@ ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() { main() {
cd "$ROOT" cd "$ROOT"
docker build --build-arg CACHEBUST="$(date +%s)-$$" \ docker build --no-cache \
--target lint \ --target lint \
--output type=cacheonly . -t "$("$SCRIPT_DIR/projectname")-lint" .
} }
main "$@" main "$@"
+4 -8
View File
@@ -2,12 +2,8 @@
# script/test: run the test suite. Testing is a phase of the Dockerfile # 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: # 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 # --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 # named, --no-cache because a cached test layer is a test that did not
# that did not run; unlike --no-cache, it keeps Go's build cache. This # run, and a tag so each build replaces the previous image.
# 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.
set -eu set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
@@ -15,9 +11,9 @@ ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() { main() {
cd "$ROOT" cd "$ROOT"
docker build --build-arg CACHEBUST="$(date +%s)-$$" \ docker build --no-cache \
--target test \ --target test \
--output type=cacheonly . -t "$("$SCRIPT_DIR/projectname")-test" .
} }
main "$@" main "$@"