diff --git a/.dockerignore b/.dockerignore index c2dfed1..d711d2c 100644 --- a/.dockerignore +++ b/.dockerignore @@ -13,12 +13,21 @@ # `/myapp`, never `**/myapp`, which also matches `cmd/myapp/` and # deletes the package directory from the context. -# Unlike the standard file, which leaves out all of .git, pixa sends -# .git without its config. Without a VERSION build argument the stage -# that compiles runs `git describe --tags --always` on .git, which does -# not need .git/config; that file can hold a credential, such as a +# .git is sent without its config. Without a VERSION build argument the +# stage 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. -.git/config +# Each submodule keeps a config with the same exposure in its git directory +# 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. # Anchored because it occurs once where agents run at the repo root. @@ -42,7 +51,9 @@ **/[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] # Dependencies: restored inside the image, never copied in. **/node_modules @@ -60,10 +71,8 @@ **/.vscode **/*.sublime-* -# pixa's own entries. Nothing in the build reads .gitignore. On the -# host, `make build` writes bin/pixad, and the example config keeps its -# state directory in data/. -.gitignore +# pixa's own entries. On the host, `make build` writes bin/pixad, and the +# example config keeps its state directory in data/. /bin /data diff --git a/.editorconfig b/.editorconfig index 92ec261..de84fae 100644 --- a/.editorconfig +++ b/.editorconfig @@ -13,3 +13,6 @@ indent_style = tab [*.go] 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. diff --git a/.gitea/workflows/check.yml b/.gitea/workflows/check.yml index bf68d24..3556efe 100644 --- a/.gitea/workflows/check.yml +++ b/.gitea/workflows/check.yml @@ -1,15 +1,21 @@ name: check 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: check: runs-on: ubuntu-latest + # Free the shared runner from a hung build. + timeout-minutes: 20 steps: # actions/checkout v4.2.2, 2026-02-22 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 - # 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. + # script/cibuild needs no token, so none is left in .git/config. with: + persist-credentials: false + # All history and tags, so git describe finds the version tag. fetch-depth: 0 - run: script/cibuild - run: script/docker-smoke diff --git a/.gitignore b/.gitignore index 8549307..6c4d819 100644 --- a/.gitignore +++ b/.gitignore @@ -17,15 +17,46 @@ Thumbs.db # entry and must not be given a `**/` prefix on the way into one. .claude/ -# Environment / secrets -.env -.env.* -*.pem -*.key +# Node +node_modules/ + +# Secrets. Unanchored like every entry above, so each matches at every +# depth. Matching is case-sensitive on Linux, so names use character +# 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 vendor/ -node_modules/ # Build output /bin/ diff --git a/.golangci.yml b/.golangci.yml index a7a74c2..32db9fe 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -17,6 +17,7 @@ linters: disable: # Genuinely incompatible with project patterns - exhaustruct # Requires all struct fields + - exhaustruct_v5 # Requires all struct fields (successor to exhaustruct) - godot # Requires comments to end with periods - wrapcheck # Too verbose for internal packages - varnamelen # Short names like db, id are idiomatic Go @@ -24,6 +25,8 @@ linters: # silenced by disabling that name, not by enabling the successor. - wsl # Deprecated, replaced by wsl_v5 - gomodguard # Deprecated, replaced by gomodguard_v2 + # Misses findings at random in v2.14.0; back once a pinned release fixes it + - canonicalheader settings: lll: line-length: 88 diff --git a/.prettierignore b/.prettierignore index 888b018..23d67fc 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,7 +1,2 @@ node_modules/ yarn.lock - -# A byte-for-byte copy of the one in sneak/prompts. -REPO_POLICIES.md - -vendor/ diff --git a/Dockerfile b/Dockerfile index e82d2d0..b2d7c80 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,12 @@ +# 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: # `make lint` and script/lint are themselves a docker build. -# golangci/golangci-lint:v2.12.2, 2026-10-04 -FROM golangci/golangci-lint@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS lint +# 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 @@ -14,17 +19,20 @@ WORKDIR /src COPY go.mod go.sum ./ RUN go mod download COPY . . -RUN golangci-lint run --config .golangci.yml ./... +RUN --mount=type=cache,target=/root/.cache/go-build \ + golangci-lint run --config .golangci.yml ./... -# Test phase. script/test builds it alone. -# 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 test +# 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 WORKDIR /src -# script/bootstrap --cgo installs the build dependencies (a C compiler, -# the libvips and libheif headers, and libvips' JPEG XL support, which -# the tests need) and downloads the Go modules. +# 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 @@ -32,11 +40,14 @@ RUN script/bootstrap --cgo COPY . . # Without -v first; on a failure, again with -v for the details, and -# the step fails even if the second run passes. -parallel 4: by default -# a package runs as many of its tests at once as the host has CPUs, and -# on a busy host they then wait so long to be scheduled that a test -# that times a request can see it return a second late. -RUN go test -count=1 -timeout 90s -race -parallel 4 -cover ./... || \ +# the step fails even if the second run passes. -count=1 because the +# mounted cache also holds test results, which go test would otherwise +# replay. -parallel 4: by default a package runs as many of its tests at +# once as the host has CPUs, and on a busy host they then wait so long +# to be scheduled that a test that times a request can see it return a +# 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 ---"; \ go test -count=1 -timeout 90s -race -parallel 4 -v ./...; exit 1; } @@ -45,38 +56,44 @@ RUN go test -count=1 -timeout 90s -race -parallel 4 -cover ./... || \ # 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 +ARG CACHEBUST COPY --from=lint /src/go.sum /dev/null COPY --from=test /src/go.sum /dev/null WORKDIR /src -# Build dependencies and Go modules, as in the test phase +# 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 # Copy source code COPY . . -# VERSION is declared here, not earlier: a new value reruns only the -# build, not script/bootstrap. Given none, the version is -# `git describe --tags --always` of the .git in the build context (git -# comes from script/bootstrap): the tag on a tagged commit, tag-N-gHASH -# after one, the short commit when no tag is reachable. A context that -# carries .git and still yields no version fails the build; one without -# .git, as from a source tarball, stamps an empty version. CGO stays -# enabled for govips; -trimpath keeps build paths out of the binary, and -# -s -w leave out the symbol table and debug information. +# The VERSION build arg when one is given, otherwise +# `git describe --tags --always` on the .git in the build context: the +# tag on a tagged commit, tag-N-gHASH after one, the short commit when +# no tag is reachable. With .git present, a version that is still empty, +# dev or unknown fails the build: git is missing or could not read the +# checkout. A context without .git, as from a source tarball, stamps an +# empty version. CGO stays enabled for govips; -trimpath keeps build +# paths out of the binary, and -s -w leave out the symbol table and +# debug information. ARG VERSION -RUN version="${VERSION:-$(git describe --tags --always)}"; \ - if [ -e .git ] && { [ -z "$version" ] || [ "$version" = dev ] || \ - [ "$version" = unknown ]; }; then \ - echo "the build context carries .git but yields no version" >&2; \ - exit 1; \ +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=1 GOTOOLCHAIN=auto go build -trimpath \ - -ldflags "-s -w -X main.Version=${version}" \ + -ldflags="-s -w -X main.Version=${VERSION}" \ -o /pixad ./cmd/pixad # Runtime stage, and the last one: a plain `docker build .` builds this @@ -85,6 +102,7 @@ RUN version="${VERSION:-$(git describe --tags --always)}"; \ # 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. diff --git a/README.md b/README.md index 61fc6c9..4e1b4c3 100644 --- a/README.md +++ b/README.md @@ -691,9 +691,9 @@ provide: install-precommit) - `script/projectname` — output the project name ("pixa") - `script/test` — run the test suite: build the `test` phase of the - `Dockerfile`, tagged `pixa-test` + `Dockerfile`, writing no image - `script/lint` — run golangci-lint: build the `lint` phase of the `Dockerfile`, - tagged `pixa-lint`; the linter never runs on the host + writing no image; the linter never runs on the host - `script/fmt` — format the Go code with gofmt and the markdown with prettier (writes) - `script/fmt-check` — check the same formatting (read-only), on the host @@ -711,15 +711,20 @@ provide: - `script/install-precommit` — install the git pre-commit hook that runs `script/precommit` -Every `docker build` in these scripts passes `--no-cache`, so the lint and test -phases run on every build instead of coming from the build cache. +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, and not `REPO_POLICIES.md` (see -`.prettierignore`), a copy of the one in `sneak/prompts`. +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 2256291..5b24098 100644 --- a/REPO_POLICIES.md +++ b/REPO_POLICIES.md @@ -1,6 +1,6 @@ --- title: Repository Policies -last_modified: 2026-09-08 +last_modified: 2026-10-15 --- This document covers repository structure, tooling, and workflow standards. Code @@ -54,34 +54,39 @@ style conventions are in separate documents: `cibuild`. `script/bootstrap` installs all dependencies idempotently and 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 - installed node if present; otherwise it installs a PINNED node version via - nvm, first installing nvm itself if missing — from a hash-verified GitHub - release archive (never `curl | sh`), with bash installed as an explicit - prerequisite since nvm requires bash. yarn is then pinned via - `corepack prepare yarn@ --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 + installed node only when its major version is the pinned one; otherwise (node + missing, or another major, such as the node 24 the CI runner image ships) it + installs the PINNED node version via nvm and installs yarn and the packages + under it. nvm itself is installed first if missing, from a hash-verified + GitHub release archive (never `curl | sh`), with bash installed as an explicit + prerequisite since nvm requires bash. Only the major version is compared + because the `Dockerfile` stages start from a node image whose exact version is + not the pin, and nvm cannot install a prebuilt node on alpine. yarn is pinned + via `corepack prepare yarn@ --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 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 - containerised gates have passed. **The bootstrap alone is not enough**: - `script/bootstrap` installs node and yarn under nvm and leaves neither on the - `PATH` of the shell that called it, so a bare `yarn` still exits 127. The host - entrypoints that need yarn — `script/fmt` and `script/fmt-check` — therefore - source nvm for the pinned node version before invoking it, exactly as - `script/bootstrap`'s own install step does. A runner carrying nothing but - docker and git then gets through `script/check`. Four further scripts are our - own extensions to the standard: `script/check` runs `script/test`, - `script/lint` and `script/fmt-check`; `script/precommit` is what the git - pre-commit hook runs, and it calls `script/check`; `script/install-precommit` - installs the git pre-commit hook (the `make hooks` target shims to it); and - `script/projectname` (literally that filename) simply outputs the project's - name. Scripts that need the name call `script/projectname` — e.g. - `script/docker` assembles its image tag from it — so those scripts stay - byte-identical across all repos. Repo-type-specific pre-commit extras (e.g. - `go mod tidy` verification in Go repos) belong in `script/precommit`, not in - the hook itself. Model scripts are at + containerised gates have passed. **The bootstrap alone is not enough**: when + `script/bootstrap` installs node and yarn under nvm it leaves neither on the + `PATH` of the shell that called it, so a bare `yarn` either exits 127 or runs + under another node. The host entrypoints that need yarn — `script/fmt` and + `script/fmt-check` — therefore use the same test as `script/bootstrap`: when + the node on `PATH` has the pinned major version they run the `yarn` on `PATH`, + and otherwise they load nvm through `$HOME/.nvm/nvm.sh` and run yarn under the + pinned node. A runner carrying nothing but docker and git then gets through + `script/check`. Four further scripts are our own extensions to the standard: + `script/check` runs `script/test`, `script/lint` and `script/fmt-check`; + `script/precommit` is what the git pre-commit hook runs, and it calls + `script/check`; `script/install-precommit` installs the git pre-commit hook + (the `make hooks` target shims to it); and `script/projectname` (literally + that filename) simply outputs the project's name. Scripts that need the name + call `script/projectname` — e.g. `script/docker` assembles its image tag from + it — so those scripts stay byte-identical across all repos. Repo-type-specific + pre-commit extras (e.g. `go mod tidy` verification in Go repos) belong in + `script/precommit`, not in the hook itself. Model scripts are at `https://git.eeqj.de/sneak/prompts/raw/branch/main/script/`. The README must document the provided scripts in an **Entrypoints** section (see the README requirements below). @@ -104,18 +109,24 @@ style conventions are in separate documents: `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 brings up a development environment; for server repos it is the runtime image. - Dockerfiles install development prerequisites by running `script/bootstrap` - rather than duplicating installs inline; COPY `script/` and the dependency - manifests (`package.json` + `yarn.lock`, `go.mod` + `go.sum`, etc.) before - running it. + The gate phases and the build stage start from their pinned base images and + install what those images lack either inline, as the canonical Go `Dockerfile` + below does for `git`, or by running `script/bootstrap`, as the `prompts` + repo's own `Dockerfile` does for its yarn packages. The development + 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 no separate lint file. `script/lint` and `script/test` each build one phase and nothing else: ```sh - docker build --no-cache --target lint -t "$(script/projectname)-lint" . - docker build --no-cache --target test -t "$(script/projectname)-test" . + docker build --build-arg CACHEBUST="$(date +%s)-$$" \ + --target lint --output type=cacheonly . + 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 @@ -125,10 +136,15 @@ style conventions are in separate documents: plain `docker build .` builds the last stage alone and exits 0 having linted and tested nothing. - **Every `docker build` in `script/` is tagged**, here and in - `script/cibuild` and `script/docker`. An untagged build leaves a dangling - image behind on every invocation, on every developer host and every CI - runner; a tagged one replaces the previous image. + **The gate builds write no image.** With `--output type=cacheonly` the phase + runs and a failing step fails the build, but the result is not exported. + Nothing uses those images, and writing one out is slow: a Go test phase's + image holds the toolchain. A build given neither `--output` nor `-t` writes + 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`, `eslint`, `prettier` — never through `make lint` or `script/test`, which are @@ -143,66 +159,107 @@ 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 cache, its own `TMPDIR` and - a digest-pinned binary, so neither is reachable. + org, in both directions. A container has its own golangci-lint cache, its + own `TMPDIR` and a digest-pinned binary, so neither is reachable. -- **Any build that runs checks is built with `--no-cache`.** Docker invalidates - a `COPY` layer only when the copied content changes, so on an unchanged tree - the check `RUN` is served from cache, nothing executes, and the build still - exits 0. Every `docker build` in `script/` therefore passes `--no-cache`: - `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. 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. +- **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 + unchanged tree the check `RUN` is served from cache, nothing executes, and the + build still exits 0. Every `docker build` in `script/` therefore passes + `--build-arg CACHEBUST="$(date +%s)-$$"`, the time and the script's process + 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 + `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 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, - and the test phase is based on the Go image. The canonical Go repo + and the test phase is based on the Debian Go image. The canonical Go repo `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 # 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 COPY . . - RUN golangci-lint run --config .golangci.yml ./... + RUN --mount=type=cache,target=/root/.cache/go-build \ + golangci-lint run --config .golangci.yml ./... - # Test phase - # golang:1.x-alpine, YYYY-MM-DD + # Test phase. -race needs cgo and so a C compiler, which the Debian Go + # image ships and the alpine one does not. -count=1 because the mounted + # 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 COPY . . - RUN go test -timeout 90s -race -cover ./... || \ + RUN --mount=type=cache,target=/root/.cache/go-build \ + go test -count=1 -timeout 90s -race -cover ./... || \ { echo "--- Rerunning with -v for details ---"; \ - go test -timeout 90s -race -v ./...; exit 1; } + 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. # 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 COPY . . - ARG VERSION=dev - RUN CGO_ENABLED=0 go build -trimpath \ - -ldflags="-s -w -X main.Version=${VERSION}" \ - -o /app ./cmd/app/ + # The VERSION build arg when one is given, otherwise + # `git describe --tags --always` on the .git in the build context. With + # .git present, a version that is still empty, dev or unknown fails the + # build: git is missing or could not read the checkout. + 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 FROM alpine@sha256:... + ARG CACHEBUST COPY --from=builder /app /usr/local/bin/app ENTRYPOINT ["app"] ``` @@ -217,23 +274,88 @@ 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`. - 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: `RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css`. - - If the project requires CGO or system libraries for linting (e.g. - `vips-dev`), install them in the lint phase with `apk add`. - - `ARG VERSION=dev` is declared in the stage that compiles and supplied by - `script/docker` and `script/cibuild`; no stage may call `git describe`. + - If the project requires CGO or system libraries for linting, install them + 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: + + ```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 - runs `script/cibuild` on push, and checks out the repo as its only other step. - That script bootstraps, runs the gate phases, and then builds the image, so a - successful run means every check passed; a bare `docker build .` does not + 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 + without it the checkout leaves the job's token in `.git/config` for every + 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 - image build is uncached and so runs the gate phases a second time. That is the - price of the rule above, and it is worth paying: the image that ships is built - from a run of its own gates rather than from a cache entry. + image build gets a `CACHEBUST` value of its own and so runs the gate phases a + second time. That is the price of the rule above, and it is worth paying: the + image that ships is built from a run of its own gates rather than from a cache + 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 JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with @@ -286,17 +408,18 @@ style conventions are in separate documents: ``` `-count=1` is required on both invocations: it defeats Go's test _result_ - cache, so the target cannot report a pass it did not earn, and the rerun - reproduces a failure instead of replaying it. It leaves the build cache - alone, so it costs the runtime of the suite and no recompilation. + cache, so neither run can report a stored pass in place of running the + tests. It leaves the build cache alone, so it costs the runtime of the suite + and no recompilation. - Note that this is a second, independent cache, stacked below the Docker - layer cache that [issue #26](https://git.eeqj.de/sneak/prompts/issues/26) - addresses. `CHECK_EPOCH` guarantees the `RUN make test` _step_ re-executes; - it does not guarantee `go test` inside that step does any work, because the - `GOCACHE` baked into earlier image layers survives into the re-executed - step. They are two separate defects requiring two separate fixes, and a fix - for one must not be recorded as covering the other. + That cache is Go's own, separate from Docker's layer cache. Go stores a + passing result in its cache directory (`GOCACHE`), and when the same tests + run again on unchanged code it prints that result, marked `(cached)`, + without running them. The directory lasts from one run to the next on a + developer's machine, where this target runs, and in the `test` phase of the + `Dockerfile`, which mounts it to keep compiled packages between check + builds, so the phase's `go test` runs carry `-count=1` too. A new + `CACHEBUST` value (above) is what makes that step run on an unchanged tree. Python example: @@ -324,9 +447,12 @@ style conventions are in separate documents: - `.gitignore` should be comprehensive from the start: OS files (`.DS_Store`), editor files (`.swp`, `*~`), in-repo agent scratch directories (`.claude/`), - language build artifacts, and `node_modules/`. Fetch the standard `.gitignore` - from `https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when - setting up a new repo. These patterns are written to `.gitignore`'s own + `node_modules/`, and the repo's own build outputs. Fetch the standard + `.gitignore` from + `https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when setting up + 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 are not a `.dockerignore` and must not be transplanted into one unmodified. @@ -340,7 +466,7 @@ style conventions are in separate documents: — 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 depth-independent pattern the `**/` prefix and leave only genuinely - root-anchored entries unprefixed: `.git`, and the repo's own host-built + root-anchored entries unprefixed: `.claude`, and the repo's own host-built binary, written `/myapp` and never `**/myapp`, which would also match `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 @@ -365,21 +491,24 @@ style conventions are in separate documents: directory, so a repo running agents in subdirectories still ships `services/api/.claude/` and must add its own anchored entry there. -- **Excluding `.git` means `git describe` cannot run inside any build stage, and - it fails quietly there.** In a build stage there is no repository, so - `git describe` writes nothing to stdout, `-X main.Version=` comes out empty, - the binary reports no version at all, and the build still exits 0. Compute the - version on the host and thread it in as a build arg. `script/docker` and - `script/cibuild` do this, byte-identically across repos: +- **A plain `docker build .` of a clone stamps the version that + `git describe --tags --always` gives**, derived from the `.git` in the build + context as the canonical `Dockerfile` above shows. Without its failure check, + a missing `git` or an unreadable checkout would leave `-X main.Version=` empty + and the build would still exit 0. `script/docker` and `script/cibuild` pass + the version they compute on the host; it takes precedence. They do this + byte-identically across repos: ```sh - # Own line: a failing command substitution inside an argument does not - # trip `set -e`, so the inline form degrades to an empty constant. + # The version and the tag each get their own line: a failing command + # substitution inside an argument does not trip `set -e`, so the inline + # form degrades to an empty constant. version="$(git describe --tags --always --dirty 2>/dev/null || true)" [ -n "$version" ] || version="unknown" - docker build --no-cache \ + tag="$(script/projectname)" + docker build --build-arg CACHEBUST="$(date +%s)-$$" \ --build-arg VERSION="$version" \ - -t "$(script/projectname)" . + -t "$tag" . ``` `--always` makes an untagged repo yield an abbreviated commit hash rather @@ -387,12 +516,12 @@ style conventions are in separate documents: 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 substitution as `|| echo unknown`, which makes the guard unreachable. The - Dockerfile's side is `ARG VERSION=dev` in the stage that compiles, declared + Dockerfile's side is `ARG VERSION` in the stage that compiles, declared 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 the scripts stay byte-identical. One consequence for CI: the standard - checkout action clones shallow and fetches no tags, so a repo that embeds a - tag-derived version must set `fetch-depth: 0` on its checkout step. + checkout action clones shallow and fetches no tags, so the canonical + `.gitea/workflows/check.yml` sets `fetch-depth: 0` on its checkout step. - **Verify `.dockerignore` by enumerating the image, not by reading the patterns.** Plant files at the root _and_ at least two directories deep, build @@ -426,12 +555,18 @@ style conventions are in separate documents: `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 re-vendor carries its entries forward. The canonical golangci-lint version is - v2.12.2 (released 2026-05-06), pinned as the digest of the lint phase's base + v2.14.0 (released 2026-09-24), pinned as the digest of the lint phase's base image - (`golangci/golangci-lint@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240`, - which reports `2.12.2 built with go1.26.2 from c0d3ddc9`). That digest is the - only pin, since no repo installs golangci-lint on the host: bumping the - version means changing it and nothing else. + (`golangci/golangci-lint@sha256:ad862ba6b3798cbe0fd9fd7408d498fd74fbd2623a92406b2fd3898faf0bf98f`, + which reports `2.14.0 built with go1.27.0 from 114493f9`). A module's `go` + directive must not name a newer Go minor version than the one golangci-lint + was built with, or golangci-lint refuses to lint it: this release lints + `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 testing presence.** An `if ! command -v ; then install; fi` guard tests @@ -453,8 +588,18 @@ style conventions are in separate documents: function defined and never invoked has the same exit status and the same 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`. + A Go tool a repo needs on the host is installed with `go install` pinned to + a commit hash (`go install @`). 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 with the version and date (YYYY-MM-DD). @@ -564,13 +709,17 @@ style conventions are in separate documents: Never edit existing migrations after release. - All repos should have an `.editorconfig` enforcing the project's indentation - settings. + settings: the standard file from + `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 - only project-level config files (`README.md`, `Makefile`, `Dockerfile`, - `LICENSE`, `.gitignore`, `.editorconfig`, `REPO_POLICIES.md`, and - language-specific config). Everything else goes in a subdirectory. Canonical - subdirectory names: + only project-level config files (`README.md`, `AGENTS.md`, `Makefile`, + `Dockerfile`, `LICENSE`, `.gitignore`, `.editorconfig`, `REPO_POLICIES.md`, + and language-specific config). Everything else goes in a subdirectory. + Canonical subdirectory names: - `bin/` — executable scripts and tools - `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 @@ -601,3 +750,7 @@ style conventions are in separate documents: - Go: `go.mod`, `go.sum`, `.golangci.yml` - JS: `package.json`, `yarn.lock`, `.prettierrc`, `.prettierignore` - 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. diff --git a/TODO.md b/TODO.md index a9f3843..af9515e 100644 --- a/TODO.md +++ b/TODO.md @@ -18,7 +18,7 @@ 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` -is at the canonical `golangci-lint` v2.12.2 config and is green. Recent work +is at the canonical `golangci-lint` v2.14.0 config and is green. Recent work extracted the internal/magic, internal/allowlist, internal/httpfetcher, and internal/signature packages. The gosec findings from the 2026-07-06 survey are resolved. The disk cache is now size-bounded with LRU eviction @@ -30,6 +30,25 @@ P2: security: per-origin rate limiting # Completed Steps +- 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. - 2026-10-15 a cached file left empty or partial is no longer served, or fetched again on every request (closes #252). Source and transformed image files, and each transformed image's `.meta` file, are synced to disk before they are diff --git a/internal/config/config.go b/internal/config/config.go index d4b78a2..2eaf698 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -823,6 +823,7 @@ func loadConfigFile(log *slog.Logger, appName string) (*smartconfig.Config, erro // 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 // fatal startup error. + //nolint:gosec // G703: a fixed config location; HOME is the operator's _, statErr := os.Stat(cleanPath) if errors.Is(statErr, fs.ErrNotExist) || errors.Is(statErr, syscall.ENOTDIR) { continue diff --git a/internal/imgcache/cachesize.go b/internal/imgcache/cachesize.go index 51d2ee3..231a907 100644 --- a/internal/imgcache/cachesize.go +++ b/internal/imgcache/cachesize.go @@ -78,10 +78,6 @@ func (c *Cache) computeDefaultMaxBytes( computed := spaceBytes / freeSpaceFractionDenominator * freeSpaceFractionNumerator 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 = max(limit, DefaultCacheMaxBytesFloor) diff --git a/script/bootstrap b/script/bootstrap index fbada47..8fd0cbe 100755 --- a/script/bootstrap +++ b/script/bootstrap @@ -3,11 +3,13 @@ # this repo. Idempotent: every install is guarded by a check so already # installed tools are skipped. Base tooling comes from nix, apt, brew, # or apk (detected in that order); assumes NOTHING is present (not git, -# make, or go). Node is used directly if installed; otherwise it is -# installed at a pinned version via nvm (installing nvm itself first, -# from a hash-verified release archive, never curl | sh). The linter is -# never installed on the host: golangci-lint runs only in the lint phase -# of the Dockerfile (see script/lint). +# make, or go). The installed node is used only when its major version +# is the pinned one; otherwise (node missing, or another major) the +# pinned version is installed via nvm (installing nvm itself first, +# from a hash-verified release archive, never curl | sh), and yarn and +# the packages are installed under it. The linter is never installed on +# 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 # prettier in yarn.lock for script/fmt and @@ -110,31 +112,50 @@ ensure_nvm() { 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() { - if ! missing node; then return 0; fi + if node_is_pinned_major; then + echo "bootstrap: using node $(node --version)" + return 0 + fi ensure_nvm nvm_sh "nvm install $NODE_VERSION" + nvm_sh "nvm use $NODE_VERSION >/dev/null && \ + echo \"bootstrap: using node \$(node --version)\"" } 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 corepack; then corepack enable 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 npm install -g "yarn@$YARN_VERSION" fi } install_js_deps() { - if missing yarn && [ -s "$HOME/.nvm/nvm.sh" ]; then + if node_is_pinned_major; then + yarn install --frozen-lockfile + else nvm_sh "nvm use $NODE_VERSION >/dev/null && cd \"$ROOT\" && \ yarn install --frozen-lockfile" - else - yarn install --frozen-lockfile fi } diff --git a/script/cibuild b/script/cibuild index 688299f..66fcca0 100755 --- a/script/cibuild +++ b/script/cibuild @@ -2,9 +2,11 @@ # script/cibuild: run the CI build. It bootstraps first: a CI runner # checks out and runs this and nothing else, and script/fmt-check runs # the formatter on the host, which a pristine checkout cannot do. -# --no-cache for the same reason as script/docker: the gate phases the -# final stage depends on are RUN steps, and a cached one is a check that -# did not run. +# 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. +# 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 SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" @@ -14,16 +16,17 @@ main() { cd "$ROOT" "$SCRIPT_DIR/bootstrap" "$SCRIPT_DIR/check" - # Own line: a failing command substitution inside an argument does - # not trip `set -e`, so the inline form degrades silently to an - # empty constant. VERSION is computed here because .dockerignore - # excludes .git, so `git describe` in a build stage yields an empty - # version without failing. + # The version and the tag each get their own line: a failing + # command substitution inside an argument does not trip `set -e`, + # so the inline form degrades silently to an empty constant. The + # VERSION build argument takes precedence over the version a build + # stage derives from the .git in the context. version="$(git describe --tags --always --dirty 2>/dev/null || true)" [ -n "$version" ] || version="unknown" - docker build --no-cache \ + tag="$("$SCRIPT_DIR/projectname")" + docker build --build-arg CACHEBUST="$(date +%s)-$$" \ --build-arg VERSION="$version" \ - -t "$("$SCRIPT_DIR/projectname")" . + -t "$tag" . } main "$@" diff --git a/script/docker b/script/docker index c4688e8..360037a 100755 --- a/script/docker +++ b/script/docker @@ -1,8 +1,11 @@ #!/bin/sh # script/docker: build the Docker image tagged with the project name. # Identical in all repos; the tag comes from script/projectname. -# --no-cache because the gate phases the final stage depends on are RUN -# steps, and a cached one is a check that did not run. +# 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. +# 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 SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" @@ -10,16 +13,17 @@ ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)" main() { cd "$ROOT" - # Own line: a failing command substitution inside an argument does - # not trip `set -e`, so the inline form degrades silently to an - # empty constant. VERSION is computed here because .dockerignore - # excludes .git, so `git describe` in a build stage yields an empty - # version without failing. + # The version and the tag each get their own line: a failing + # command substitution inside an argument does not trip `set -e`, + # so the inline form degrades silently to an empty constant. The + # VERSION build argument takes precedence over the version a build + # stage derives from the .git in the context. version="$(git describe --tags --always --dirty 2>/dev/null || true)" [ -n "$version" ] || version="unknown" - docker build --no-cache \ + tag="$("$SCRIPT_DIR/projectname")" + docker build --build-arg CACHEBUST="$(date +%s)-$$" \ --build-arg VERSION="$version" \ - -t "$("$SCRIPT_DIR/projectname")" . + -t "$tag" . } main "$@" diff --git a/script/fmt b/script/fmt index c7b584a..a110cc3 100755 --- a/script/fmt +++ b/script/fmt @@ -7,15 +7,25 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" # Must match the pin in script/bootstrap. NODE_VERSION="22.17.0" -# script/bootstrap installs node and yarn under nvm and leaves neither -# on the PATH of the shell that called it, so resolve the pinned -# toolchain here the way bootstrap's own install step does. nvm is a -# bash script, hence the subshell. +# True when the node on PATH has the pinned major version: the same +# function as in script/bootstrap, which uses it to decide where it +# installs yarn. +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%%.*}" ] +} + +# 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() { - if command -v yarn >/dev/null 2>&1; then + if node_is_pinned_major && command -v yarn >/dev/null 2>&1; then exec yarn "$@" fi - if [ ! -s "$HOME/.nvm/nvm.sh" ]; then + if node_is_pinned_major || [ ! -s "$HOME/.nvm/nvm.sh" ]; then echo "fmt: no yarn; run script/bootstrap first" >&2 exit 1 fi diff --git a/script/fmt-check b/script/fmt-check index b8d3f00..9964da7 100755 --- a/script/fmt-check +++ b/script/fmt-check @@ -8,15 +8,25 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" # Must match the pin in script/bootstrap. NODE_VERSION="22.17.0" -# script/bootstrap installs node and yarn under nvm and leaves neither -# on the PATH of the shell that called it, so resolve the pinned -# toolchain here the way bootstrap's own install step does. nvm is a -# bash script, hence the subshell. +# True when the node on PATH has the pinned major version: the same +# function as in script/bootstrap, which uses it to decide where it +# installs yarn. +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%%.*}" ] +} + +# 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() { - if command -v yarn >/dev/null 2>&1; then + if node_is_pinned_major && command -v yarn >/dev/null 2>&1; then exec yarn "$@" fi - if [ ! -s "$HOME/.nvm/nvm.sh" ]; then + if node_is_pinned_major || [ ! -s "$HOME/.nvm/nvm.sh" ]; then echo "fmt-check: no yarn; run script/bootstrap first" >&2 exit 1 fi diff --git a/script/lint b/script/lint index 2d8b075..c4c459e 100755 --- a/script/lint +++ b/script/lint @@ -5,9 +5,12 @@ # make its answer untrustworthy. # # The phase is not the last stage in the file, so it is built only when -# --target names it. --no-cache because a cached lint layer is a lint -# that did not run. The tag makes each build replace the previous image -# instead of leaving a dangling one behind. +# --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. +# --output type=cacheonly writes no image, since nothing uses one. set -eu SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" @@ -15,9 +18,9 @@ ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)" main() { cd "$ROOT" - docker build --no-cache \ + docker build --build-arg CACHEBUST="$(date +%s)-$$" \ --target lint \ - -t "$("$SCRIPT_DIR/projectname")-lint" . + --output type=cacheonly . } main "$@" diff --git a/script/test b/script/test index cd239f2..a56c4a1 100755 --- a/script/test +++ b/script/test @@ -2,8 +2,12 @@ # script/test: run the test suite. Testing is a phase of the Dockerfile # and this builds that phase alone, on the same terms as script/lint: # --target because a phase that is not the last stage is built only when -# named, --no-cache because a cached test layer is a test that did not -# run, and a tag so each build replaces the previous image. +# 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. set -eu SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" @@ -11,9 +15,9 @@ ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)" main() { cd "$ROOT" - docker build --no-cache \ + docker build --build-arg CACHEBUST="$(date +%s)-$$" \ --target test \ - -t "$("$SCRIPT_DIR/projectname")-test" . + --output type=cacheonly . } main "$@"