diff --git a/TODO.md b/TODO.md index 9fe14ec..e9708c6 100644 --- a/TODO.md +++ b/TODO.md @@ -21,18 +21,18 @@ fmt-check, and commit. # Completed Steps -- 2026-11-07: Go code is now formatted with `gofmt` and then `golines -m 77` - (https://git.eeqj.de/sneak/prompts/issues/160). `prompts/REPO_POLICIES.md` - says how `script/fmt`, `script/fmt-check` and `script/bootstrap` do it and - pins `golines`. The Go style guide and both checklists say the same. `lll` - stays at 88, since `golines` leaves long strings and comments as they are. -- 2026-11-06: Compacted `prompts/REPO_POLICIES.md` from 1,145 lines and 8,427 - words to 1,066 lines and 7,812 words +- 2026-11-09: Compacted `prompts/REPO_POLICIES.md` from 1,165 lines and 8,556 + words to 973 lines and 7,261 words (https://git.eeqj.de/sneak/prompts/issues/158). Every rule keeps its meaning. The rules now sit under headings, the pinning rule first. Each rule that was stated in several places is stated once. The Makefile copies of the Go and Python test commands are gone, because the `Dockerfile` examples carry the same commands. Both checklists name the `.golangci.yml` section. +- 2026-11-07: Go code is now formatted with `gofmt` and then `golines -m 77` + (https://git.eeqj.de/sneak/prompts/issues/160). `prompts/REPO_POLICIES.md` + says how `script/fmt`, `script/fmt-check` and `script/bootstrap` do it and + pins `golines`. The Go style guide and both checklists say the same. `lll` + stays at 88, since `golines` leaves long strings and comments as they are. - 2026-11-06: The pinning rule in `prompts/REPO_POLICIES.md` now opens with its scope: what we download and install ourselves in scripts and Dockerfiles (https://git.eeqj.de/sneak/prompts/issues/154). Distribution packages are diff --git a/examples/go-lint-system-libraries.Dockerfile b/examples/go-lint-system-libraries.Dockerfile new file mode 100644 index 0000000..49699d7 --- /dev/null +++ b/examples/go-lint-system-libraries.Dockerfile @@ -0,0 +1,8 @@ +FROM golangci/golangci-lint@sha256:... AS lint +RUN apt-get update \ + && apt-get install -y --no-install-recommends libvips-dev \ + && rm -rf /var/lib/apt/lists/* +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +ARG CACHEBUST diff --git a/examples/go.Dockerfile b/examples/go.Dockerfile new file mode 100644 index 0000000..2128902 --- /dev/null +++ b/examples/go.Dockerfile @@ -0,0 +1,85 @@ +# In every stage ARG CACHEBUST comes right after the steps that install +# system packages and download Go's modules, or right after FROM in a +# stage with neither, and every step that lints, tests or compiles +# comes after it. script/ passes a new value on every build, which +# reruns every RUN step after it. Docker keeps the layers above it +# until the base image, their own lines or go.mod and go.sum change. +# The steps that compile mount Go's build cache, and golangci-lint run +# also mounts golangci-lint's; both survive from build to build. + +# Lint phase +# golangci/golangci-lint:v2.x.x, YYYY-MM-DD +FROM golangci/golangci-lint@sha256:... AS lint +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +ARG CACHEBUST +COPY . . +RUN --mount=type=cache,target=/root/.cache/go-build \ + --mount=type=cache,target=/root/.cache/golangci-lint \ + golangci-lint run --config .golangci.yml ./... + +# Test phase. -race needs cgo and so a C compiler, which the Debian Go +# image ships and the alpine one does not. -count=1 because the mounted +# cache also holds test results, which go test would otherwise replay. +# A first run that timed out gets no rerun, because a rerun would only +# wait for the same hang. +# golang:1.x, YYYY-MM-DD +FROM golang@sha256:... AS test +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +ARG CACHEBUST +COPY . . +RUN --mount=type=cache,target=/root/.cache/go-build \ + status=0; \ + output=$(go test -count=1 -timeout 90s -race -cover ./... 2>&1) || status=$?; \ + printf '%s\n' "$output"; \ + test "$status" -eq 0 && exit 0; \ + case "$output" in \ + *"panic: test timed out"*|*"*** Test killed"*) exit 1 ;; \ + esac; \ + echo "--- Rerunning with -v for details ---"; \ + 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 nothing after them can +# run unless lint and test passed. +# golang:1.x-alpine, YYYY-MM-DD +FROM golang@sha256:... AS builder +# git comes from the base image's own distribution package index, which +# is trusted, so it needs no version and no hash. +RUN apk add --no-cache git +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +ARG CACHEBUST +COPY --from=lint /src/go.sum /dev/null +COPY --from=test /src/go.sum /dev/null +# A tar-stream context keeps the sender's file owners, which git refuses. +RUN git config --system --add safe.directory /src +COPY . . + +# The VERSION build arg when one is given, otherwise +# `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"] diff --git a/examples/python-test-phase.Dockerfile b/examples/python-test-phase.Dockerfile new file mode 100644 index 0000000..e1209bb --- /dev/null +++ b/examples/python-test-phase.Dockerfile @@ -0,0 +1,24 @@ +# Test phase. Both pytest runs share one 90-second timeout, so a hang +# uses it once and gets no rerun. +# debian:trixie-slim, YYYY-MM-DD +FROM debian@sha256:... AS test +# Python, pip and venv come from the distribution, which is trusted, so +# they need no version and no hash. make and git are base tools that +# script/bootstrap installs when they are missing. Installed here, they +# stay in their own layer when script/ changes. +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates make git \ + python3 python3-venv \ + && rm -rf /var/lib/apt/lists/* +# script/bootstrap installs pipenv in a virtual environment at +# $HOME/.pipenv, and the tests below run that pipenv. +ENV PATH=/root/.pipenv/bin:$PATH +WORKDIR /app +COPY script/ script/ +COPY Pipfile Pipfile.lock ./ +RUN script/bootstrap +ARG CACHEBUST +COPY . . +RUN timeout 90 sh -c 'pipenv run pytest || { \ + echo "--- Rerunning with -v for details ---"; \ + pipenv run pytest -v; exit 1; }' diff --git a/prompts/EXISTING_REPO_CHECKLIST.md b/prompts/EXISTING_REPO_CHECKLIST.md index dae2bf2..96f76e0 100644 --- a/prompts/EXISTING_REPO_CHECKLIST.md +++ b/prompts/EXISTING_REPO_CHECKLIST.md @@ -1,6 +1,6 @@ --- title: Existing Repo Checklist -last_modified: 2026-11-07 +last_modified: 2026-11-09 --- Use this checklist when beginning work in a repo that may not yet conform to our @@ -71,8 +71,8 @@ with your task. also mounts golangci-lint's (`--mount=type=cache,target=/root/.cache/golangci-lint`), and no step mounts anything else; both `go test` runs carry `-count=1`; a first run - that timed out gets no rerun, as in the canonical Go `Dockerfile` in - `REPO_POLICIES.md` + that timed out gets no rerun, as in `examples/go.Dockerfile` in the + `prompts` repo - [ ] Python: a `test` phase that installs its packages from `Pipfile.lock` does it above `ARG CACHEBUST`, with `COPY script/ script/`, `COPY Pipfile Pipfile.lock ./` and `RUN script/bootstrap`. When @@ -92,8 +92,8 @@ with your task. installs no Python, because a step above it already did. `Pipfile` names no `python_version`, because `--deploy` stops when the distribution's Python differs from it. The tests run after `ARG CACHEBUST`, and every other stage - installs its Python packages after it, as in the Python `test` phase example - in `REPO_POLICIES.md` + installs its Python packages after it, as in + `examples/python-test-phase.Dockerfile` in the `prompts` repo - [ ] The gate phases invoke their tools directly, never through `make lint` or `script/test` — those are themselves a `docker build` and would recurse diff --git a/prompts/NEW_REPO_CHECKLIST.md b/prompts/NEW_REPO_CHECKLIST.md index 67776ba..3903042 100644 --- a/prompts/NEW_REPO_CHECKLIST.md +++ b/prompts/NEW_REPO_CHECKLIST.md @@ -1,6 +1,6 @@ --- title: New Repo Checklist -last_modified: 2026-11-07 +last_modified: 2026-11-09 --- Use this checklist when creating a new repository from scratch. Follow the steps @@ -138,8 +138,8 @@ Template files can be fetched from: also mounts golangci-lint's (`--mount=type=cache,target=/root/.cache/golangci-lint`), and no step mounts anything else; both `go test` runs carry `-count=1`; a first run - that timed out gets no rerun, as in the canonical Go `Dockerfile` in - `REPO_POLICIES.md` + that timed out gets no rerun, as in `examples/go.Dockerfile` in the + `prompts` repo - Python: a `test` phase that installs its packages from `Pipfile.lock` does it above `ARG CACHEBUST`, with `COPY script/ script/`, `COPY Pipfile Pipfile.lock ./` and `RUN script/bootstrap`. When @@ -160,8 +160,8 @@ Template files can be fetched from: because a step above it already did. `Pipfile` names no `python_version`, because `--deploy` stops when the distribution's Python differs from it. The tests run after `ARG CACHEBUST`, and every - other stage installs its Python packages after it, as in the Python - `test` phase example in `REPO_POLICIES.md` + other stage installs its Python packages after it, as in + `examples/python-test-phase.Dockerfile` in the `prompts` repo - Server: the final stage builds and runs the application - Non-server: the final stage brings up the dev environment diff --git a/prompts/REPO_POLICIES.md b/prompts/REPO_POLICIES.md index 8a0c197..e3b0230 100644 --- a/prompts/REPO_POLICIES.md +++ b/prompts/REPO_POLICIES.md @@ -182,10 +182,10 @@ package/suite summaries. Failure shows every test case and every assertion. hang. A hung package therefore uses the 90-second backstop once. The command lives in the `test` phase of the `Dockerfile`, since `script/test` -builds that phase. The canonical Go `Dockerfile` and the Python `test` phase -example below carry it. The Makefile form below is the same pattern for any -repo-local invocation. In a Makefile, each `$` of those two commands is written -`$$`. +builds that phase. `examples/go.Dockerfile` and +`examples/python-test-phase.Dockerfile` in the `prompts` repo carry it. The +Makefile form below is the same pattern for any repo-local invocation. In a +Makefile, each `$` of those two commands is written `$$`. ```makefile test: @@ -212,7 +212,9 @@ Use `yarn`, not `npm`. - `script/bootstrap` installs node, yarn and the node packages only when `yarn.lock` is in the tree. A `Dockerfile` stage that needs no yarn copies no `yarn.lock` before it runs `script/bootstrap`, so `script/bootstrap` installs - no node and no yarn there. The Go and Python stages below are such stages. + no node and no yarn there. The stages in the `prompts` repo's + `examples/go.Dockerfile` and `examples/python-test-phase.Dockerfile` are such + stages. - It installs the node packages with `yarn install --immutable`, which fails when a package's `checksum` is wrong, or when `package.json` is out of step with `yarn.lock`. @@ -329,7 +331,7 @@ What each stage installs: - The gate phases and the build stage start from their pinned base images. They install what those images lack in either of these ways: - - inline, as the canonical Go `Dockerfile` below does for `git` + - inline, as the `prompts` repo's `examples/go.Dockerfile` does for `git` - by running `script/bootstrap`, as the `prompts` repo's own `Dockerfile` does for its yarn packages - The development environment stage installs development prerequisites by @@ -375,8 +377,8 @@ step that also installs anything else comes after `ARG CACHEBUST` too. A repository re-vendors the scripts and adds the `ARG` lines in one commit. - The scripts do not pass `--no-cache`. That flag reruns every step too, but it also rebuilds the layers above the line and empties every cache mount. The - canonical Go `Dockerfile` below mounts Go's build cache and golangci-lint's so - that they survive between check builds. + `prompts` repo's `examples/go.Dockerfile` mounts Go's build cache and + golangci-lint's so that they survive between check builds. - 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 @@ -393,93 +395,8 @@ so lint failures surface in seconds rather than after a full compile. That image has both Go and the linter, so nothing needs installing. The test phase is based on the Debian Go image. -```dockerfile -# In every stage ARG CACHEBUST comes right after the steps that install -# system packages and download Go's modules, or right after FROM in a -# stage with neither, and every step that lints, tests or compiles -# comes after it. script/ passes a new value on every build, which -# reruns every RUN step after it. Docker keeps the layers above it -# until the base image, their own lines or go.mod and go.sum change. -# The steps that compile mount Go's build cache, and golangci-lint run -# also mounts golangci-lint's; both survive from build to build. - -# Lint phase -# golangci/golangci-lint:v2.x.x, YYYY-MM-DD -FROM golangci/golangci-lint@sha256:... AS lint -WORKDIR /src -COPY go.mod go.sum ./ -RUN go mod download -ARG CACHEBUST -COPY . . -RUN --mount=type=cache,target=/root/.cache/go-build \ - --mount=type=cache,target=/root/.cache/golangci-lint \ - golangci-lint run --config .golangci.yml ./... - -# Test phase. -race needs cgo and so a C compiler, which the Debian Go -# image ships and the alpine one does not. -count=1 because the mounted -# cache also holds test results, which go test would otherwise replay. -# A first run that timed out gets no rerun, because a rerun would only -# wait for the same hang. -# golang:1.x, YYYY-MM-DD -FROM golang@sha256:... AS test -WORKDIR /src -COPY go.mod go.sum ./ -RUN go mod download -ARG CACHEBUST -COPY . . -RUN --mount=type=cache,target=/root/.cache/go-build \ - status=0; \ - output=$(go test -count=1 -timeout 90s -race -cover ./... 2>&1) || status=$?; \ - printf '%s\n' "$output"; \ - test "$status" -eq 0 && exit 0; \ - case "$output" in \ - *"panic: test timed out"*|*"*** Test killed"*) exit 1 ;; \ - esac; \ - echo "--- Rerunning with -v for details ---"; \ - 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 nothing after them can -# run unless lint and test passed. -# golang:1.x-alpine, YYYY-MM-DD -FROM golang@sha256:... AS builder -# git comes from the base image's own distribution package index, which -# is trusted, so it needs no version and no hash. -RUN apk add --no-cache git -WORKDIR /src -COPY go.mod go.sum ./ -RUN go mod download -ARG CACHEBUST -COPY --from=lint /src/go.sum /dev/null -COPY --from=test /src/go.sum /dev/null -# A tar-stream context keeps the sender's file owners, which git refuses. -RUN git config --system --add safe.directory /src -COPY . . - -# The VERSION build arg when one is given, otherwise -# `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"] -``` +The canonical Go `Dockerfile` is at +`https://git.eeqj.de/sneak/prompts/raw/branch/main/examples/go.Dockerfile`. Key points: @@ -527,18 +444,9 @@ Key points: where alpine says `vips-dev`), and delete the package lists in the same `RUN`, so the layer does not keep them. The test phase, on the Debian Go image, installs the libraries its tests need the same way. Either install goes right - after `FROM`, above `ARG CACHEBUST`: - - ```dockerfile - FROM golangci/golangci-lint@sha256:... AS lint - RUN apt-get update \ - && apt-get install -y --no-install-recommends libvips-dev \ - && rm -rf /var/lib/apt/lists/* - WORKDIR /src - COPY go.mod go.sum ./ - RUN go mod download - ARG CACHEBUST - ``` + after `FROM`, above `ARG CACHEBUST`. A lint phase that installs system + libraries is at + `https://git.eeqj.de/sneak/prompts/raw/branch/main/examples/go-lint-system-libraries.Dockerfile`. ## Python `test` phase @@ -629,37 +537,12 @@ not package `pip-tools`. Make the first `Pipfile.lock` with `$HOME/.pipenv/bin/pipenv lock`, after the `pip install` line has installed pipenv. -In the example below, `script/bootstrap` finds `make`, `git`, python and venv -already installed: +In `examples/python-test-phase.Dockerfile`, `script/bootstrap` finds `make`, +`git`, python and venv already installed. The Python `test` phase example is at +`https://git.eeqj.de/sneak/prompts/raw/branch/main/examples/python-test-phase.Dockerfile`. -```dockerfile -# Test phase. Both pytest runs share one 90-second timeout, so a hang -# uses it once and gets no rerun. -# debian:trixie-slim, YYYY-MM-DD -FROM debian@sha256:... AS test -# Python, pip and venv come from the distribution, which is trusted, so -# they need no version and no hash. make and git are base tools that -# script/bootstrap installs when they are missing. Installed here, they -# stay in their own layer when script/ changes. -RUN apt-get update \ - && apt-get install -y --no-install-recommends ca-certificates make git \ - python3 python3-venv \ - && rm -rf /var/lib/apt/lists/* -# script/bootstrap installs pipenv in a virtual environment at -# $HOME/.pipenv, and the tests below run that pipenv. -ENV PATH=/root/.pipenv/bin:$PATH -WORKDIR /app -COPY script/ script/ -COPY Pipfile Pipfile.lock ./ -RUN script/bootstrap -ARG CACHEBUST -COPY . . -RUN timeout 90 sh -c 'pipenv run pytest || { \ - echo "--- Rerunning with -v for details ---"; \ - pipenv run pytest -v; exit 1; }' -``` - -Run from a `make test` target on a host, that test command: +Run from a `make test` target on a host, the test command in +`examples/python-test-phase.Dockerfile`: - does not stop on Ctrl-C. It goes on until the tests end or the 90 seconds run out. @@ -677,7 +560,7 @@ Run from a `make test` target on a host, that test command: - the short commit, when no tag is reachable The stage that compiles derives it from the `.git` in the build context, as the -canonical Go `Dockerfile` above shows: +`prompts` repo's `examples/go.Dockerfile` shows: - It takes the version from the `VERSION` build argument when one is given, otherwise from `git describe`. `ARG VERSION` is declared in that stage,