From 7feb212d04381519a100442c5eb4bd0691cf3841 Mon Sep 17 00:00:00 2001 From: sneak Date: Wed, 4 Nov 2026 07:19:46 -0800 Subject: [PATCH] Adopt the scripts-to-rule-them-all layout and the canonical Docker gates script/ holds the entrypoints and the Makefile targets shim to them. Linting and testing are phases of the Dockerfile, built alone by script/lint and script/test with a fresh CACHEBUST value and cache mounts for Go's build cache and golangci-lint's; the build stage depends on both, and the runtime stage ships the binary and the source without .git. Formatting stays on the host in script/fmt and script/fmt-check (gofmt and the prettier pinned in yarn.lock). script/cibuild bootstraps, runs script/check and builds the tagged image, and the Gitea workflow calls it. script/precommit refuses a commit whose go.mod or go.sum is not tidy. cgo stays off, so the test phase runs on the alpine Go image without -race; that is the one departure from the canonical Go Dockerfile. Re-vendored from the prompts repository's next branch: REPO_POLICIES.md (2026-11-04), .golangci.yml (byte-identical, so the local exhaustruct_v5 addition is gone now that canonical carries it), .gitea/workflows/check.yml, .editorconfig, .gitignore and .dockerignore, the last two followed by this repository's own entries. AGENTS.md holds the repository-specific guidance for agents. The README gains the Entrypoints section and describes the new gates. --- .dockerignore | 87 ++- .editorconfig | 6 + .gitea/workflows/check.yml | 13 +- .gitignore | 46 +- .golangci.yml | 43 +- AGENTS.md | 19 + Dockerfile | 111 ++-- Makefile | 133 ++--- README.md | 78 ++- REPO_POLICIES.md | 1042 ++++++++++++++++++++++++++---------- script/bootstrap | 104 ++++ script/check | 16 + script/cibuild | 33 ++ script/docker | 30 ++ script/fmt | 29 + script/fmt-check | 34 ++ script/install-precommit | 16 + script/lint | 28 + script/precommit | 22 + script/projectname | 12 + script/setup | 13 + script/test | 24 + 22 files changed, 1492 insertions(+), 447 deletions(-) create mode 100644 AGENTS.md create mode 100755 script/bootstrap create mode 100755 script/check create mode 100755 script/cibuild create mode 100755 script/docker create mode 100755 script/fmt create mode 100755 script/fmt-check create mode 100755 script/install-precommit create mode 100755 script/lint create mode 100755 script/precommit create mode 100755 script/projectname create mode 100755 script/setup create mode 100755 script/test diff --git a/.dockerignore b/.dockerignore index f850155..5fdf624 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,9 +1,80 @@ -.git -node_modules -.DS_Store +# .dockerignore does NOT use .gitignore semantics. Docker matches with +# moby/patternmatcher: filepath.Match plus `**`, so `*` does not cross +# `/` and an unprefixed pattern is anchored at the context root. Every +# depth-independent pattern therefore needs `**/`, or `config/.env` and +# `certs/server.key` still ship while this file reads as solved. Only +# genuinely root-anchored entries go unprefixed. Never transplant these +# into .gitignore, where `**/` is wrong. +# +# Matching is case-sensitive, so secrets use character ranges rather +# than an ALL-CAPS twin, which would still miss `Server.Key`. +# +# Extend with this repo's own host-built artifacts, written anchored: +# `/myapp`, never `**/myapp`, which also matches `cmd/myapp/` and +# deletes the package directory from the context. + +# .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. +# 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. +# KNOWN GAP: a repo running agents in subdirectories still ships +# `services/api/.claude/` and must add its own anchored entry. +.claude + +# Environment files. `*.env` covers bare `.env` and the `prod.env` +# convention. Re-include a committed template with a negation if the +# build needs one: `!docs/example.env`. +**/*.[eE][nN][vV] +**/.[eE][nN][vV].* +**/.[eE][nN][vV][rR][cC] + +# Private keys and the bundles carrying them. Public certificates +# (*.crt, *.cer) are deliberately absent: they are legitimate inputs. +**/*.[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] + +# Dependencies: restored inside the image, never copied in. +**/node_modules + +# OS metadata. +**/.DS_Store +**/Thumbs.db + +# Editor state: never a build input, and it churns COPY. +**/*.swp +**/*.swo +**/*~ +**/*.bak +**/.idea +**/.vscode +**/*.sublime-* + +# This repository's own host-built artifacts, anchored. /sutil -*.test -*.out -*.log -*.prof -coverage.* +**/*.test +**/*.out +**/*.log +**/*.prof +**/coverage.* diff --git a/.editorconfig b/.editorconfig index 2fe0ce0..de84fae 100644 --- a/.editorconfig +++ b/.editorconfig @@ -10,3 +10,9 @@ insert_final_newline = true [Makefile] 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 aca7a51..2e96965 100644 --- a/.gitea/workflows/check.yml +++ b/.gitea/workflows/check.yml @@ -1,9 +1,20 @@ 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 - - run: docker build . + # 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 diff --git a/.gitignore b/.gitignore index 9296457..d9d899b 100644 --- a/.gitignore +++ b/.gitignore @@ -11,17 +11,49 @@ Thumbs.db .vscode/ *.sublime-* -# Node (prettier for markdown formatting) +# Agent scratch (worktrees of this repo, created and destroyed by +# in-flight tooling). Unanchored: .gitignore patterns already match at +# every depth, so no prefix is wanted here. This is not a .dockerignore +# entry and must not be given a `**/` prefix on the way into one. +.claude/ + +# Node node_modules/ -# Environment / secrets -.env -.env.* -*.pem -*.key +# 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`. -# Go build and test artifacts +# 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/`. + +# The sutil binary that `make build` writes at the root. /sutil + +# Go test and profiling outputs. *.test *.out *.log diff --git a/.golangci.yml b/.golangci.yml index 3d06276..251f85c 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -3,6 +3,9 @@ version: "2" # Config schema uses the golangci-lint v2 layout (settings live under # linters.settings, not top-level linters-settings) so that the # thresholds below are actually applied by golangci-lint >= v2. +# +# This file has no formatters section, so golangci-lint run does not run +# gofmt. A repository's script/fmt-check runs it, on the host. run: timeout: 5m @@ -16,13 +19,13 @@ linters: - gomodguard_v2 disable: # Genuinely incompatible with project patterns - - exhaustruct # Requires all struct fields - - exhaustruct_v5 # Same linter, newer major version (since v2.13.0) + - exhaustruct_v5 # Requires all struct fields - godot # Requires comments to end with periods - wrapcheck # Too verbose for internal packages - varnamelen # Short names like db, id are idiomatic Go # Deprecated: the warning is attached to the old name, so it is # silenced by disabling that name, not by enabling the successor. + - exhaustruct # Deprecated, replaced by exhaustruct_v5 - wsl # Deprecated, replaced by wsl_v5 - gomodguard # Deprecated, replaced by gomodguard_v2 settings: @@ -40,7 +43,10 @@ linters: # test-support package exists to hand a test privileges the program # itself must never have, so a file that is not a test must not import # one. Test files, and the files inside a package whose directory name - # ends in `test`, are where that code belongs, and are exempt. + # ends in `test`, are where that code belongs, and are exempt. A package + # in a subdirectory of that directory is not exempt. `${base-path}` is + # the directory holding this file, so the name of the directory a + # repository is cloned into does not count. # # The deny list below is the one part of this file a repository is # expected to extend, and the only part it may. depguard matches an @@ -55,24 +61,25 @@ linters: files: - "$all" - "!$test" - - "!**/*test/**" + - "!${base-path}**/*test/*" deny: - pkg: net/http/httptest desc: >- Test-support code belongs in test files and in packages whose directory name ends in test, not in the shipped binary. - # Only decisions already recorded in the Go package defaults are - # listed here. Every entry matches the module path exactly. + # Each entry names a module not to use and what to use instead. Every + # entry matches the module path exactly. gomodguard_v2: blocked: - module: github.com/rs/zerolog recommendations: - log/slog reason: "Structured logging is stdlib log/slog." - # One entry per pre-fork module path, because the later releases - # are separate paths. A prefix match would be shorter but would - # also reach github.com/go-redis/redismock, the test double for - # the successor these entries recommend. + # These entries cover only the github.com/go-redis/redis module paths, + # one entry per path, because the later releases are separate paths. + # A prefix match would be shorter but would also reach + # github.com/go-redis/redismock/v9, the test double for the successor + # these entries recommend. - module: github.com/go-redis/redis recommendations: - github.com/redis/go-redis/v9 @@ -85,6 +92,10 @@ linters: recommendations: - github.com/redis/go-redis/v9 reason: "Pre-fork module; use the maintained go-redis v9." + - module: github.com/go-redis/redis/v9 + recommendations: + - github.com/redis/go-redis/v9 + reason: "Pre-fork module; use the maintained go-redis v9." - module: github.com/sergi/go-diff recommendations: - github.com/aymanbagabas/go-udiff @@ -93,7 +104,19 @@ linters: recommendations: - github.com/aymanbagabas/go-udiff reason: "Unmaintained fork; use go-udiff." + exclusions: + rules: + # homoicon keeps a local copy of sobek, its JavaScript engine, as sneak + # decided in + # https://git.eeqj.de/sneak/homoicon/issues/1759#issuecomment-153261. + # This is an exclusion because gomoddirectives does not check its + # replace-allow-list for a replace that points at a local path. + - path: ^go\.mod$ + linters: + - gomoddirectives + text: '^local replacement are not allowed: github\.com/grafana/sobek$' issues: max-issues-per-linter: 0 max-same-issues: 0 + uniq-by-line: false diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..ef3388c --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,19 @@ +# AGENTS.md + +Guidance for coding agents working in this repository. The repository policies +are in `REPO_POLICIES.md`, vendored from the `prompts` repository; read that +first. Everything below is specific to this repository, and the README's Design +section explains each point. + +- `pkg/` holds only packages designed to be imported by other programs. Anything + that exists to support `sutil` lives under `internal/`. +- `sutil` is not a command-line front end for the packages in `pkg/`. It holds + only the utilities the author wants available everywhere; a command is added + because a tool is wanted, not because a package exists. +- `pkg/cbk` derives crypto_box keypairs from BIP39 mnemonics with BIP85 and does + nothing else. BIP39 passphrases are deliberately unsupported. +- cgo stays off: builds are static, and the test phase runs without `-race`. +- Version tags are created by the author alone, when the author decides. Do not + propose, schedule, or create tags. +- `.golangci.yml` is byte-identical to the canonical copy and is not edited + here; changes go to the `prompts` repository. diff --git a/Dockerfile b/Dockerfile index 4d2deb3..b24084f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,56 +1,95 @@ -# The checks behind `make check` are split across independent stages so that -# formatting and lint failures surface in seconds, before any compilation: +# 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 - gofmt and golangci-lint (make fmt-check-go, make lint) -# docs - prettier check of all markdown (make fmt-check-md) -# builder - tests (make test) and the release build -# -# The builder stage declares explicit dependencies on lint and docs so -# BuildKit cannot finish the build unless every check passed. +# This repository keeps cgo off, so its test phase departs from the +# canonical Go Dockerfile in REPO_POLICIES.md in one way: it runs on the +# alpine Go image without -race, which needs a C toolchain. Everything +# else follows the canonical file. -# Lint stage: fast feedback on Go formatting and lint issues -# golangci/golangci-lint:v2.15.0, 2026-10-16 (keep in sync with Makefile) +# Lint phase +# golangci/golangci-lint:v2.15.0, 2026-10-16 FROM golangci/golangci-lint@sha256:fbfcfd12a6f8d7f1b97b3362ca80fc2a244d46863148c73cb88e7793af96c481 AS lint WORKDIR /src COPY go.mod go.sum ./ RUN go mod download +ARG CACHEBUST COPY . . -RUN make fmt-check-go -RUN make lint +RUN --mount=type=cache,target=/root/.cache/go-build \ + --mount=type=cache,target=/root/.cache/golangci-lint \ + golangci-lint run --config .golangci.yml ./... -# Docs stage: markdown formatting check with the pinned prettier -# node:24-alpine, 2026-10-28 -FROM node@sha256:8a32c8cf649a49915a66e81e7a609223c708d0adabf288ee3f4077d765542f85 AS docs -RUN apk add --no-cache make -WORKDIR /src -COPY package.json yarn.lock ./ -RUN yarn install --frozen-lockfile -COPY . . -RUN make fmt-check-md - -# Build stage +# Test phase. -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.26-alpine, 2026-10-27 -FROM golang@sha256:a2012b4a1d1f9f7e9f44e0d39b805a901e856b1dd80b4e6ee4338db547050308 AS builder -RUN apk add --no-cache make +FROM golang@sha256:a2012b4a1d1f9f7e9f44e0d39b805a901e856b1dd80b4e6ee4338db547050308 AS test +ENV CGO_ENABLED=0 WORKDIR /src - -# Force BuildKit to run the lint and docs stages before proceeding -COPY --from=lint /src/go.sum /dev/null -COPY --from=docs /src/package.json /dev/null - COPY go.mod go.sum ./ RUN go mod download +ARG CACHEBUST COPY . . -RUN make test +RUN --mount=type=cache,target=/root/.cache/go-build \ + status=0; \ + output=$(go test -count=1 -timeout 90s -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 -v ./...; \ + exit 1 -ARG VERSION=dev -RUN CGO_ENABLED=0 go build -trimpath \ - -ldflags="-s -w -X main.Version=${VERSION}" \ - -o /sutil ./cmd/sutil +# 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.26-alpine, 2026-10-27 +FROM golang@sha256:a2012b4a1d1f9f7e9f44e0d39b805a901e856b1dd80b4e6ee4338db547050308 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 . . -# Runtime stage: the binary plus the full source it was built from +# 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 /sutil ./cmd/sutil +# The runtime stage ships the source; the repository history is not +# part of it. +RUN rm -rf /src/.git + +# Runtime stage, and the last one: the binary plus the source it was +# built from. # alpine:3.24, 2026-10-23 FROM alpine@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a +ARG CACHEBUST COPY --from=builder /sutil /usr/local/bin/sutil COPY --from=builder /src /usr/local/src/x ENTRYPOINT ["sutil"] diff --git a/Makefile b/Makefile index 353e0cd..ec3d18c 100644 --- a/Makefile +++ b/Makefile @@ -1,30 +1,45 @@ -# sneak.berlin/go/x -# -# This Makefile is the single source of truth for how this repo is built, -# tested, linted, and formatted. Always use these targets rather than -# invoking the underlying tools directly. +.PHONY: bootstrap setup test lint fmt fmt-check check docker hooks \ + build install tidy clean -.PHONY: all build install test lint fmt fmt-go fmt-md fmt-check \ - fmt-check-go fmt-check-md check docker hooks tidy setup clean +# Makefile targets are thin shims; the implementations live in script/ +# per the scripts-to-rule-them-all pattern (see the Entrypoints section +# of README.md). The targets after the shims are this repo's own. + +bootstrap: + @script/bootstrap + +setup: + @script/setup + +test: + @script/test + +lint: + @script/lint + +fmt: + @script/fmt + +fmt-check: + @script/fmt-check + +check: + @script/check + +docker: + @script/docker + +hooks: + @script/install-precommit + +# This repository's own targets. BINARY := sutil -VERSION := $(shell git describe --always --dirty 2>/dev/null || echo dev) +VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo unknown) GOLDFLAGS := -X main.Version=$(VERSION) -GO_DIRS := cmd internal pkg -# golangci/golangci-lint:v2.15.0, 2026-10-16 (keep in sync with Dockerfile) -GOLANGCI_LINT_IMAGE := golangci/golangci-lint@sha256:fbfcfd12a6f8d7f1b97b3362ca80fc2a244d46863148c73cb88e7793af96c481 - -# flags are repeated here (also in .prettierrc) so this Makefile works -# standalone when copied as a template -PRETTIER := yarn run --silent prettier -PRETTIER_FLAGS := --tab-width 4 --prose-wrap always -MARKDOWN_GLOB := '**/*.md' - -all: build - -# cgo stays off everywhere: builds are static and the tests do not use -# -race, which would require a C toolchain. +# cgo stays off everywhere: builds are static, and the test phase of the +# Dockerfile runs without -race, which would need a C toolchain. export CGO_ENABLED := 0 # Build the sutil binary in the repo root with the git version embedded. @@ -35,81 +50,9 @@ build: install: go install -trimpath -ldflags "$(GOLDFLAGS)" ./cmd/$(BINARY) -# Run the tests quietly; on failure rerun verbosely for diagnostics and -# still fail. -test: - @go test -timeout 30s -cover ./... || \ - { echo "--- Rerunning with -v for details ---"; \ - go test -timeout 30s -v ./...; exit 1; } - -# Lint with the pinned golangci-lint image when docker is available so local -# results match CI exactly. Inside the Docker build (where there is no -# docker) the image's own golangci-lint binary is used. The host module -# cache is shared read-only; the linter and build caches live in named -# volumes so repeated runs are fast. -# -# .golangci.yml is verified against the linter's schema first: golangci-lint -# otherwise ignores keys it does not know and silently runs with defaults. -LINT_COMMAND := golangci-lint config verify && golangci-lint run ./... - -lint: - @if command -v docker >/dev/null 2>&1; then \ - docker run --rm \ - -v "$(CURDIR)":/src \ - -v "$$(go env GOMODCACHE)":/go/pkg/mod:ro \ - -v sutil-golangci-lint-cache:/root/.cache/golangci-lint \ - -v sutil-go-build-cache:/root/.cache/go-build \ - -w /src $(GOLANGCI_LINT_IMAGE) sh -c '$(LINT_COMMAND)'; \ - else \ - $(LINT_COMMAND); \ - fi - -# Format everything (writes files). -fmt: fmt-go fmt-md - -fmt-go: - gofmt -w $(GO_DIRS) - -fmt-md: - $(PRETTIER) --write $(MARKDOWN_GLOB) $(PRETTIER_FLAGS) - -# Check formatting without writing anything. -fmt-check: fmt-check-go fmt-check-md - -fmt-check-go: - @unformatted="$$(gofmt -l $(GO_DIRS))"; \ - if [ -n "$$unformatted" ]; then \ - echo "gofmt: the following files need formatting:"; \ - echo "$$unformatted"; \ - exit 1; \ - fi - -fmt-check-md: - $(PRETTIER) --check $(MARKDOWN_GLOB) $(PRETTIER_FLAGS) - -# Everything CI gates on. Must not modify any files. -check: test lint fmt-check - -# Build the Docker image; the Dockerfile runs the same checks as `make check`. -docker: - docker build -t $(BINARY) . - -# Install the pre-commit hook. -hooks: - @printf '#!/bin/sh\nset -e\n' > .git/hooks/pre-commit - @if [ -f go.mod ]; then \ - printf 'go mod tidy\ngo fmt ./...\ngit diff --exit-code -- go.mod go.sum || { echo "go mod tidy changed files; please stage and retry"; exit 1; }\n' >> .git/hooks/pre-commit; \ - fi - @printf 'make check\n' >> .git/hooks/pre-commit - @chmod +x .git/hooks/pre-commit - -# Tidy go.mod and go.sum. +# Tidy go.mod and go.sum; script/precommit refuses a commit that needs it. tidy: go mod tidy -# One-time developer setup: install the pinned prettier for markdown checks. -setup: - yarn install --frozen-lockfile - clean: rm -f $(BINARY) diff --git a/README.md b/README.md index 05e7de5..7ca87f2 100644 --- a/README.md +++ b/README.md @@ -217,38 +217,74 @@ added because a tool is wanted, not because a package exists. ### Build, test, and CI -The `Makefile` is the single entry point: `make test` (30-second timeout, quiet -unless something fails), `make lint`, `make fmt` and `make fmt-check` (gofmt for -Go, prettier for markdown), `make check` for all three, `make build`, -`make install`, `make docker`, `make hooks`, `make tidy`, and `make setup` to -install the pinned prettier. +The gates live in the `Dockerfile`, in the canonical Go layout that +`REPO_POLICIES.md` prescribes: a `lint` phase on the hash-pinned golangci-lint +image and a `test` phase on the Go image. `script/lint` and `script/test` each +build one phase alone, with a fresh `CACHEBUST` value so the check step runs on +every build instead of being served from Docker's cache, and write no image. The +build stage depends on both phases, so the image cannot be built unless they +pass. Go's build cache and golangci-lint's cache are mounted into the phases and +survive between builds. Formatting is the one check that runs on the host: +`script/fmt-check` runs gofmt and the prettier pinned in `yarn.lock`. +`script/check` runs all three. `script/cibuild`, which the Gitea workflow calls, +bootstraps, runs `script/check`, and then builds the tagged image with the +version from `git describe`. -`make lint` runs golangci-lint inside the same hash-pinned Docker image the CI -build uses, so local results match CI exactly, and first verifies -`.golangci.yml` against the linter's schema, because golangci-lint otherwise -ignores keys it does not know and silently runs with defaults. The `Dockerfile` -splits `make check` across stages: a lint stage and a markdown-check stage fail -fast in seconds, and the build stage, which runs the tests and compiles `sutil`, -depends on both. The runtime image carries the binary and the source it was -built from. Every external reference, from base images to the GitHub Action in +Every external reference, from base images to the GitHub Action in `.gitea/workflows/check.yml`, is pinned by content hash with a version and date -comment. `.golangci.yml` is this repo's copy of the standard configuration, kept -in the v2 layout with `exhaustruct_v5` and the deprecated `gomodguard` disabled. -cgo stays off: the Makefile exports `CGO_ENABLED=0` for every build and test, so -binaries are static and no C toolchain is needed anywhere. +comment. `.golangci.yml` is byte-identical to the canonical copy in the +`prompts` repository and is never edited here; the lint phase pins the +golangci-lint release that the policy names. cgo stays off: the Makefile exports +`CGO_ENABLED=0`, binaries are static, and the test phase runs on the alpine Go +image without `-race`, which is this repository's one departure from the +canonical `Dockerfile`. The runtime image carries the binary and the source it +was built from. Version tags are created by the author alone, when the author decides. Tooling, contributors, and agents working in this repository do not propose, schedule, or create tags. +## Entrypoints + +This repository adheres to the +[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all) +standard: normalized scripts in `script/` are the entrypoints for the +development workflow, and the Makefile targets are thin shims that call them. We +provide: + +- `script/bootstrap`: install everything the repository needs, idempotently: + make, git, go, and, because `yarn.lock` is present, node, yarn 1 and the + pinned prettier. +- `script/setup`: make a fresh clone ready for development by running + `script/bootstrap` and then `script/install-precommit`. +- `script/projectname`: print the project name, `x`, which `script/docker` and + `script/cibuild` use as the image tag. +- `script/test`: build the `test` phase of the `Dockerfile` with a new + `CACHEBUST` value, writing no image. +- `script/lint`: build the `lint` phase the same way. +- `script/fmt`: gofmt and prettier, writing files. +- `script/fmt-check`: the read-only twin of `script/fmt`. +- `script/check`: `script/test`, `script/lint`, then `script/fmt-check`. It + modifies nothing. +- `script/docker`: build the image tagged `x`, passing the version from + `git describe`. +- `script/cibuild`: what CI runs: `script/bootstrap`, `script/check`, then the + tagged image build. +- `script/precommit`: run by the pre-commit hook. It refuses a commit whose + `go.mod` or `go.sum` is not tidy, then runs `script/check`. +- `script/install-precommit`: install that hook; `make hooks` shims to it. + +The Makefile adds four targets of its own: `make build`, `make install`, +`make tidy`, and `make clean`. + ## Development ```sh -make setup # once: install the pinned prettier with yarn -make check # test + lint + fmt-check, exactly what CI gates on +make setup # once: bootstrap and install the pre-commit hook +make check # test, lint, fmt-check: what the hook and CI run make fmt # gofmt and prettier, writes files -make hooks # install the pre-commit hook that runs make check -make docker # the full CI build +make build # build ./sutil on the host +make docker # build the image tagged x ``` ## TODO diff --git a/REPO_POLICIES.md b/REPO_POLICIES.md index 2256291..6667f8e 100644 --- a/REPO_POLICIES.md +++ b/REPO_POLICIES.md @@ -1,6 +1,6 @@ --- title: Repository Policies -last_modified: 2026-09-08 +last_modified: 2026-11-04 --- This document covers repository structure, tooling, and workflow standards. Code @@ -12,6 +12,7 @@ style conventions are in separate documents: - [JavaScript](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_JS.md) - [Python](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_PYTHON.md) - [Go HTTP Server Conventions](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/GO_HTTP_SERVER_CONVENTIONS.md) +- [API Policies](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/API_POLICIES.md) --- @@ -19,72 +20,157 @@ style conventions are in separate documents: `last_modified: YYYY-MM-DD` in the YAML front matter so it can be kept in sync with the authoritative source as policies evolve. -- **ALL external references must be pinned by cryptographic hash.** This - includes Docker base images, Go modules, npm packages, GitHub Actions, and - anything else fetched from a remote source. Version tags (`@v4`, `@latest`, - `:3.21`, etc.) are server-mutable and therefore remote code execution - vulnerabilities. The ONLY acceptable way to reference an external dependency - is by its content hash (Docker `@sha256:...`, Go module hash in `go.sum`, npm - integrity hash in lockfile, GitHub Actions `@`). No exceptions. - This also means never `curl | bash` to install tools like pyenv, nvm, rustup, - etc. Instead, download a specific release archive from GitHub, verify its hash - (hardcoded in the Dockerfile or script), and only then install. Unverified - install scripts are arbitrary remote code execution. This is the single most - important rule in this document. Double-check every external reference in - every file before committing. There are zero exceptions to this rule. +- **External references must be pinned by cryptographic hash.** This is the + single most important rule in this document. It covers everything fetched from + a remote source, including: + - Docker base images + - Go modules + - Python packages + - npm packages + - GitHub Actions + + A version tag such as `@v4`, `@latest` or `:3.21` is server-mutable, and + therefore a remote code execution vulnerability. Reference an external + dependency by its content hash: + - Docker: `@sha256:...` + - Go: the module hash in `go.sum` + - Python: the package hash in `Pipfile.lock` + - npm: the integrity hash in the lockfile + - GitHub Actions: `@` + + This also means never `curl | bash` to install a tool such as rustup. + Instead, download a specific release archive from GitHub, verify its hash, + and only then install. The hash is hardcoded in the Dockerfile or script. + Unverified install scripts are arbitrary remote code execution. Double-check + every external reference in every file before committing. + + There is one exception: a package from the distribution's own package index + needs no version and no hash. Pulling that index is also allowed. The + distribution is trusted. In a `Dockerfile`, it is the pinned base image's + distribution. On a host, it is the host's. The host's package manager stands + for its distribution, and that includes nix and brew. These commands are + examples: + - `apt-get update` + - `apt-get install` + - `apk update` + - `apk add` + + These come from the distribution wherever possible: + - node + - yarn + - npm + - python + - pip and venv, the installer tools bundled with python + + If the distribution's version is too old, use a newer distribution, never a + download. That rules out all of these: + - nvm + - pyenv + - corepack + - `npm install -g yarn` + + These are not trusted, so everything from them stays pinned by hash: + - Docker Hub + - PyPI + - the npm registry + - `go get` + + A package source that a `Dockerfile` adds itself is not the distribution's + own, so the exception does not cover it. - Every repo with software must have a root `Makefile` with these targets: - `make bootstrap`, `make setup`, `make test`, `make lint`, `make fmt` (writes), - `make fmt-check` (read-only), `make check` (runs `test`, `lint`, `fmt-check`), - `make docker`, and `make hooks` (installs pre-commit hook). A model Makefile - is at `https://git.eeqj.de/sneak/prompts/raw/branch/main/Makefile`. + - `make bootstrap` + - `make setup` + - `make test` + - `make lint` + - `make fmt` (writes) + - `make fmt-check` (read-only) + - `make check` (runs `test`, `lint`, `fmt-check`) + - `make docker` + - `make hooks` (installs pre-commit hook) + + A model Makefile is at + `https://git.eeqj.de/sneak/prompts/raw/branch/main/Makefile`. - Repos follow the [Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all) - pattern: the implementation of each Makefile target lives in an executable - script in `script/` (`script/bootstrap`, `script/setup`, `script/test`, - `script/lint`, `script/fmt`, `script/fmt-check`, `script/check`, - `script/docker`), and the Makefile targets are thin shims that call them. The - scripts must be POSIX sh (`#!/bin/sh`, `set -eu`, no bashisms) so they run in - minimal containers (e.g. alpine images have no bash); locate the repo root - with `$(cd "$(dirname "$0")/.." && pwd -P)` and `cd` there before acting. From - the standard's canonical set we use `bootstrap`, `setup` (make the repo ready - for development after a fresh clone: runs `bootstrap`, then - `install-precommit`, plus any repo-specific initialization), `test`, and - `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 - `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 - `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). + pattern. The implementation of each Makefile target lives in an executable + script in `script/`, and the Makefile targets are thin shims that call them: + - `script/bootstrap` + - `script/setup` + - `script/test` + - `script/lint` + - `script/fmt` + - `script/fmt-check` + - `script/check` + - `script/docker` + + The scripts must be POSIX sh (`#!/bin/sh`, `set -eu`, no bashisms) so they + run in minimal containers; alpine images, for example, have no bash. Locate + the repo root with `$(cd "$(dirname "$0")/.." && pwd -P)`, and `cd` there + before acting. + + From the standard's canonical set we use `bootstrap`, `setup`, `test`, and + `cibuild`. `setup` makes the repo ready for development after a fresh clone: + it runs `bootstrap`, then `install-precommit`, plus any repo-specific + initialization. + + `script/bootstrap` installs all dependencies idempotently and assumes + nothing is present. Base tools, node and yarn come from nix, apt, brew, or + apk, detected in that order; apt runs noninteractive. Each one is installed + only when it is missing. A tool from outside the distribution is never + installed as "latest" or "lts"; it is always an exact version. + + `script/bootstrap` installs node, yarn and the node packages only when + `yarn.lock` is in the tree. With no `yarn.lock`, it installs no node and no + yarn, and it does not check yarn's version. Make a repository's first + `yarn.lock` with yarn 1, from a distribution that packages it, such as + Alpine (`apk add nodejs yarn`). + + Debian and Ubuntu name the yarn command `yarnpkg`. `script/bootstrap`, + `script/fmt` and `script/fmt-check` each carry the same small function that + runs whichever name is present. With neither name on `PATH`, `script/fmt` + and `script/fmt-check` print one line saying to run `script/bootstrap`, and + exit 1. + + With `yarn.lock` in the tree, `script/bootstrap` stops with a one-line + message when yarn's major version is not 1, before it installs any node + packages. Debian 13 and Ubuntu 26.04 package yarn 4. With + `--frozen-lockfile`, yarn 4 refuses a yarn 1 `yarn.lock`. Without that flag, + it rewrites the file in its own format, with yarn 4 hashes in place of the + yarn 1 ones. A host whose distribution packages yarn 4 moves to one that + packages yarn 1, such as Alpine or Ubuntu 24.04. + + A `Dockerfile` stage copies `yarn.lock` before it runs `script/bootstrap` + only if that stage needs yarn. A stage that needs yarn starts from an image + whose distribution packages yarn 1, such as Alpine. The Python and Go stages + below need no yarn and copy no `yarn.lock`, so they never reach the stop. + + `script/cibuild` runs the CI build, and the Gitea workflow calls it. The + script changes to the repo root, runs `script/bootstrap`, runs + `script/check`, and builds the image with the version. **`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. + + 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. + - `script/projectname` (literally that filename) simply outputs the + project's name. + + Scripts that need the name call `script/projectname`, so those scripts stay + byte-identical across all repos. `script/docker`, for example, assembles its + image tag from it. Repo-type-specific pre-commit extras belong in + `script/precommit`, not in the hook itself; `go mod tidy` verification in Go + repos is one. 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). - Always use Makefile targets (`make fmt`, `make test`, `make lint`, etc.) instead of invoking the underlying tools directly. The Makefile is the single @@ -92,165 +178,486 @@ style conventions are in separate documents: - The Makefile is authoritative documentation for how the repo is used. Beyond the required targets above, it should have targets for every common operation: - running a local development server (`make run`, `make dev`), re-initializing - or migrating the database (`make db-reset`, `make migrate`), building - artifacts (`make build`), generating code, seeding data, or anything else a - developer would do regularly. If someone checks out the repo and types - `make`, they should see every meaningful operation available. A new - contributor should be able to understand the entire development workflow by - reading the Makefile. + - running a local development server (`make run`, `make dev`) + - re-initializing or migrating the database (`make db-reset`, + `make migrate`) + - building artifacts (`make build`) + - generating code + - seeding data + - anything else a developer would do regularly + + If someone checks out the repo and types `make`, they should see every + meaningful operation available. A new contributor should be able to + understand the entire development workflow by reading the Makefile. - Every repo should have a `Dockerfile`, and it carries the repo's gates: a - `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. + `lint` phase and a `test` phase. The final stage depends 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. The + gate phases and the build stage start from their pinned base images and + install what those images lack in either of these ways: + - inline, as the canonical Go `Dockerfile` below 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 + running `script/bootstrap`. Only system packages may be installed inline + above it, so that check builds keep them; the `prompts` repo's own + `Dockerfile` does this. A stage that runs `script/bootstrap` COPYs `script/` + and the dependency manifests it needs (`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 stage's chain depends on it, or when `--target` names it.** That is why the two gates are always invoked by name here, and why the final stage carries a - `COPY --from=` of a harmless file from each of them: without that edge a - plain `docker build .` builds the last stage alone and exits 0 having linted - and tested nothing. + `COPY --from=` of a harmless file from each of them. Without that + dependency, a 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 script 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 - themselves a `docker build` and would recurse into a daemon that does not - exist in a build step. Formatting is the exception and stays on the host: - `script/fmt` writes the working tree, and `script/fmt-check` is its - read-only twin. + Inside a phase the tool is invoked directly: `golangci-lint`, `go test`, + `eslint`, `prettier`. The tool is never invoked through `make lint` or + `script/test`. Those are themselves a `docker build`, and would recurse into + a daemon that does not exist in a build step. Formatting is the exception + and stays on the host: `script/fmt` writes the working tree, and + `script/fmt-check` is its read-only twin. **No lint verdict may come from a host invocation of the linter.** On a shared host golangci-lint reads a result cache keyed on file content rather - than location, so a second checkout of the same content is served the first - 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. + than location. A second checkout of the same content is therefore served the + first one's findings. A host-global lock in `$TMPDIR` also 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 `TMPDIR`, so the lock + is out of reach. The lint phase does keep golangci-lint's cache between + check builds (below), but it lints every checkout at `/src`. A finding is + taken from that cache only when all of these are the same: + - the files + - the settings + - the golangci-lint version -- **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. On an + unchanged tree the check `RUN` is therefore 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`. Four scripts do this, and there is no fifth: + - `script/lint` + - `script/test` + - `script/cibuild` + - `script/docker` + + `script/check` runs the two gate phases and `script/fmt-check`, and builds + no image of its own. In every stage of the `Dockerfile`, every step that + lints, tests or compiles comes after `ARG CACHEBUST`. `ARG CACHEBUST` comes + right after these steps: + - the steps that install system packages + - the steps that download Go's modules + - the step that runs `script/bootstrap` to install a Python `test` phase's + packages from `Pipfile.lock` + + A stage with none of these steps declares `ARG CACHEBUST` right after + `FROM`. A step that also installs anything else comes after `ARG CACHEBUST` + too. A `script/bootstrap` that also installs node packages is one. A new + value reruns every `RUN` step after that line; `COPY` steps are compared by + content and need nothing. Docker keeps the layers above the line until one + of these changes: + - the base image + - their own lines + - the files they copy + + 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`. That flag reruns every step too, but + it also builds the layers above the line again and empties every cache + mount. Go's build cache and golangci-lint's would then start empty every + time; the canonical Go `Dockerfile` below mounts them 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 + build cache shared with every other build on the host. When a check is added + or changed, prove it works: + - plant a defect it must catch + - watch the run fail on it + - 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 + hash), so lint failures surface in seconds rather than after a full compile. + The test phase is based on the Debian Go image. The canonical Go repo `Dockerfile`: ```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 golangci-lint run --config .golangci.yml ./... + 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 - # 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. + # 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 go test -timeout 90s -race -cover ./... || \ - { echo "--- Rerunning with -v for details ---"; \ - go test -timeout 90s -race -v ./...; exit 1; } + 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 this stage cannot run - # unless lint and test passed. + # are what make BuildKit build them first, so nothing after them can + # run unless lint and test passed. # golang:1.x-alpine, YYYY-MM-DD FROM golang@sha256:... AS builder - COPY --from=lint /src/go.sum /dev/null - COPY --from=test /src/go.sum /dev/null + # 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 . . - 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"] ``` Key points: - - The lint phase uses the `golangci/golangci-lint` image directly (it has - both Go and the linter), so nothing needs installing. + - The lint phase uses the `golangci/golangci-lint` image directly, so + nothing needs installing: it has both Go and the linter. - `COPY --from= /src/go.sum /dev/null` is a no-op copy whose only - purpose is the ordering edge. BuildKit runs stages in parallel by default, - and a stage nothing depends on is not built at all, so without these two - lines a red gate would not fail the build. - - 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. - - 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: + purpose is the dependency on that phase. BuildKit runs stages in parallel + by default, and a stage nothing depends on is not built at all. Without + these two lines a red gate would not fail the build. + - Keep the runtime stage last. 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. + - Check builds keep these, and none of them can hand back a stale result: + - The layers above `ARG CACHEBUST`: the system packages and Go's + modules. Docker installs the packages again when the base image or the + install line changes. Docker downloads the modules again when `go.mod` + or `go.sum` changes. Go checks every module against `go.sum` as it + downloads it, and no later build can change a kept layer. The modules + are kept in a layer rather than a cache mount. A cache mount is shared + by every build on the host, and Go builds from whatever files the + mount holds without checking them again (only `go mod verify` does). A + file changed there would go unnoticed. + - Go's build cache (`/root/.cache/go-build`, where Go keeps it for + root), mounted on the steps that compile: `golangci-lint run`, both + `go test` runs and `go build`. Go reuses a compiled package only when + all its inputs are unchanged. The cache also holds test results, which + is why both `go test` runs carry `-count=1`. + - golangci-lint's cache (`/root/.cache/golangci-lint`, where the pinned + image keeps it), mounted on `golangci-lint run`. golangci-lint reuses + a package's findings only when all of these are unchanged: + - the package's files and those of every package it imports + - `go.mod` + - the linters and their settings in `.golangci.yml` + - the golangci-lint version + - In a Python `test` phase, the layer of the step that runs + `script/bootstrap` to install the packages from `Pipfile.lock`, as + described for that phase below. + - If the project uses `//go:embed` directives that reference build + artifacts, the lint phase must create placeholder files so the embed + directives resolve. A web frontend compiled in a separate stage is such an + artifact. 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. The test phase, + on the Debian Go image, installs the libraries its tests need the same + way. Either install goes right after `FROM`, above `ARG CACHEBUST`, so + Docker keeps it until the base image or the install line changes: + + ```dockerfile + FROM golangci/golangci-lint@sha256:... AS lint + RUN apt-get update \ + && apt-get install -y --no-install-recommends libvips-dev \ + && rm -rf /var/lib/apt/lists/* + WORKDIR /src + COPY go.mod go.sum ./ + RUN go mod download + ARG CACHEBUST + ``` + + - `.dockerignore` lets `.git` into the build context. It keeps out every git + `config` at any depth (`**/.git/config`, `**/.git/modules/**/config`): + - the repository's own + - each submodule's under `.git/modules/` + - 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. Some submodule names have a `config` segment, as in `config`, + `deploy/config` or `config/lib`. Such a submodule 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, and an alpine one needs + `apk add --no-cache git`. That stage 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 + - the tag, the number of commits since it and the short commit + (`v1.2.3-4-gabc1234`), on a later commit + - 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. 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 is the exception: + a linked worktree, or a repository checked out as a submodule. 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. + +- **A Python `test` phase that installs its packages from `Pipfile.lock` does it + above `ARG CACHEBUST`, so check builds keep them.** The steps are: + - `COPY script/ script/` + - `COPY Pipfile Pipfile.lock ./` + - `RUN script/bootstrap` + + `ARG CACHEBUST` comes right after them. Docker keeps what that `RUN` + installed until one of these changes: + - the base image + - `script/` + - `Pipfile` + - `Pipfile.lock` + - a line up to and including the `RUN` + + The tests run after `ARG CACHEBUST`, on every check build. In every other + stage, a step that installs Python packages comes after `ARG CACHEBUST`. + - When `script/bootstrap` runs in that step, it installs nothing beyond + these: + - system packages + - pipenv and its dependencies + - the packages from `Pipfile.lock` + + `script/bootstrap` installs no Python there: the tests use the + distribution's Python, which a step above it installs. If it installs + anything else there, the step goes after `ARG CACHEBUST`. + + - Debian's Python refuses a system-wide `pip install`, so pipenv goes in a + virtual environment at `$HOME/.pipenv`. `script/bootstrap` creates it with + `python3 -m venv` when it is missing, then uses that environment's `pip` + and `pipenv`. On a host, it first installs the distribution's python and + venv packages when they are missing. One script then works on a host and + in a `Dockerfile`. `Pipfile` names no `python_version`, because + `pipenv install --deploy` stops when the distribution's Python differs + from it. + - PyPI is not trusted, so `script/bootstrap` installs pipenv and its + dependencies by hash. It does so with `pip install --require-hashes -r` + and a requirements file in `script/` that lists each of them with `==` and + its `--hash`. pip refuses to install a dependency the file does not list. + - `script/bootstrap` then runs `pipenv install --deploy`, with `--dev` when + the tests need the development packages. `--deploy` does all of these: + - installs only what `Pipfile.lock` names + - checks every package against the lockfile's hashes + - fails when the lockfile is out of date with `Pipfile` + - `script/bootstrap` sets `PIP_ONLY_BINARY=:all:` for both installs, and + pipenv passes it on to pip. A package with no ready-built wheel for the + image then stops the install. Without the setting, pip builds that package + from source and downloads the build tools without a hash check. + - A kept layer holds nothing a new install would refuse: pip checked the + hashes of pipenv and its dependencies, and pipenv checked the hashes of + the lockfile's packages. pipenv creates its virtual environment with the + `virtualenv` package. The packages that `virtualenv` adds come from wheels + inside that package, so the hash pip checked for it covers them. System + packages, Python among them, are not fixed by a hash. They keep the + versions the package manager gave when the layer was built; a new install + may get newer ones. + - As with Go's modules, the packages are kept in a layer, not a cache mount. + A cache mount is shared by every build on the host, and nothing checks its + files again. + + A Python repository's `script/bootstrap` adds these lines in `main`, above + the last `echo`. The `ensurepip` test fails when python is missing, and also + when Debian's venv package is missing: + + ```sh + if [ ! -x "$HOME/.pipenv/bin/pip" ]; then + if ! python3 -m ensurepip --version >/dev/null 2>&1; then + pkg_install python3 python3-venv python3 python3 + fi + python3 -m venv "$HOME/.pipenv" + fi + export PIP_ONLY_BINARY=:all: + "$HOME/.pipenv/bin/pip" install --require-hashes -r script/requirements.txt + "$HOME/.pipenv/bin/pipenv" install --dev --deploy + ``` + + Make `script/requirements.txt` once, with + `echo pipenv | pip-compile --generate-hashes --allow-unsafe -o script/requirements.txt -`. + `pip-compile` comes from Alpine's `py3-pip-tools` package; Debian and Ubuntu + do 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. The example copies no `yarn.lock`, so + `script/bootstrap` installs no node and no yarn. + + ```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; }' + ``` - 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. The workflow's only other step checks out the + repo, with `persist-credentials: false`. `script/cibuild` needs no token, and + without that setting 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`. + A new push then 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, each held to the 5-minute Docker build limit below, plus the + bootstrap. Those builds are the test phase, the lint phase, then the image. 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 - two exceptions: four-space indents (except Go), and `proseWrap: always` for - Markdown (hard-wrap at 80 columns). Documentation and writing repos (Markdown, - HTML, CSS) should also have `.prettierrc` and `.prettierignore`. +- Use platform-standard formatters: + - `black` for Python + - `prettier` for JS/CSS/Markdown/HTML + - `go fmt` for Go + + Always use default configuration, with two exceptions: + - four-space indents (except Go) + - `proseWrap: always` for Markdown (hard-wrap at 80 columns) + + Documentation and writing repos (Markdown, HTML, CSS) should also have + `.prettierrc` and `.prettierignore`. - Pre-commit hook: runs `script/precommit`, which calls `script/check`. If local testing is not possible in the repo, `script/precommit` may skip `script/test` - and run only `script/lint` and `script/fmt-check`. The hook is installed by - `script/install-precommit`; the Makefile must provide a `make hooks` target - that shims to it. + and run only `script/lint` and `script/fmt-check`. `script/install-precommit` + installs the hook; the Makefile must provide a `make hooks` target that shims + to it. - All repos with software must have tests that run via the platform-standard test framework (`go test`, `pytest`, `jest`/`vitest`, etc.). If no meaningful - tests exist yet, add the most minimal test possible — e.g. importing the - module under test to verify it compiles/parses. There is no excuse for + tests exist yet, add the most minimal test possible: importing the module + under test to verify it compiles/parses, for example. There is no excuse for `make test` to be a no-op. - `make test` must complete in under 60 seconds. That is the hard cap, and a @@ -259,15 +666,19 @@ style conventions are in separate documents: improvement bug against that repo. Add a 90-second timeout to the test invocation (`go test -timeout 90s`). The backstop deliberately sits above the hard cap so that it catches a genuinely hung test rather than a merely slow - one. + one. A hung package uses the backstop once, because a first run that timed out + gets no rerun. `-timeout` applies to each package, so several hung packages + can each use it. - **The test command should use the conditional verbose rerun pattern.** Run tests without `-v` (verbose) first. If tests fail, automatically rerun with `-v` to show full output. This keeps CI logs and `docker build` output clean - on success (just package/suite summaries) while providing full diagnostic - detail on failure (every test case, every assertion). The command lives in the - `test` phase of the `Dockerfile`, since `script/test` builds that phase; the - Makefile form below is the same pattern for any repo-local invocation: + on success, with just package/suite summaries. On failure it provides full + diagnostic detail: every test case, every assertion. A first run that timed + out gets no rerun, because the rerun would only wait for the same hang. The + command lives in the `test` phase of the `Dockerfile`, since `script/test` + builds that phase. The Makefile form below is the same pattern for any + repo-local invocation: ```makefile test: @@ -280,39 +691,66 @@ style conventions are in separate documents: ```makefile test: - @go test -count=1 -timeout 90s -race -cover ./... || \ - { echo "--- Rerunning with -v for details ---"; \ - go test -count=1 -timeout 90s -race -v ./...; exit 1; } + @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 ``` - `-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. + The first run's output is kept so that the recipe can look in it for what Go + prints for a hang: + - `panic: test timed out` means the test's own timer fired. + - `*** Test killed` means `go test` stopped a test that ignored that timer, + about a minute later. - 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. + Go prints the goroutine stacks with either line, as long as the test can + still respond. A rerun would only wait for the same hang, so the recipe + exits 1. A failure with neither line in its output reruns with `-v`. The + kept output prints when the first run ends, not while it runs. + + `-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 + tests. It leaves the build cache 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 + passing result in its cache directory (`GOCACHE`). 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. The directory also lasts 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: ```makefile test: - @python -m pytest || \ - { echo "--- Rerunning with -v for details ---"; \ - python -m pytest -v; exit 1; } + @timeout 90 sh -c 'pipenv run pytest || { \ + echo "--- Rerunning with -v for details ---"; \ + pipenv run pytest -v; exit 1; }' ``` - The `exit 1` ensures the target always fails after a rerun — the first run + Both pytest runs share that one 90-second timeout, as in the `Dockerfile` + example above, so a hang uses it once and gets no rerun. Ctrl-C does not + stop a running `make test`, which goes on until the tests end or the 90 + seconds run out. On a Mac without `timeout`, the target fails until it is + installed. On a host, the target needs `$HOME/.pipenv/bin` on `PATH`. + + The `exit 1` ensures the target always fails after a rerun. The first run already proved the tests are broken, so the build must not pass even if a flaky test happens to succeed on the second attempt. The rerun exists solely for diagnostic output. -- Docker builds must complete in under 5 minutes. +- Docker builds must complete in under 5 minutes. A hung package uses the + 90-second test backstop once, because a first run that timed out gets no + rerun. - `make check` must not modify any files in the repo. Tests may use temporary directories. @@ -322,91 +760,108 @@ style conventions are in separate documents: - Never commit secrets. `.env` files, credentials, API keys, and private keys must be in `.gitignore`. No exceptions. -- `.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 - semantics, in which an unanchored pattern already matches at every depth; they - are not a `.dockerignore` and must not be transplanted into one unmodified. +- `.gitignore` should be comprehensive from the start: + - OS files (`.DS_Store`) + - editor files (`.swp`, `*~`) + - in-repo agent scratch directories (`.claude/`) + - `node_modules/` + - 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. - **`.dockerignore` does not use `.gitignore` semantics, and copying patterns across unmodified leaves secrets in the build context.** Docker matches with - `moby/patternmatcher`: `filepath.Match` semantics plus a `**` extension, so - `*` does not cross `/` and a pattern without a leading `**/` is anchored at + `moby/patternmatcher`: `filepath.Match` semantics plus a `**` extension. So + `*` does not cross `/`, and a pattern without a leading `**/` is anchored at the build-context root. A `.dockerignore` listing `.env`, `*.pem` and `*.key` - therefore excludes only the copies at the repository root, while `config/.env` - and `certs/server.key` still reach the context and can land in an image layer - — 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 - 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 - secret names use character ranges — `**/*.[kK][eE][yY]`, `**/*.[pP][eE][mM]`, - and likewise for `.envrc` and the extensionless SSH keys. Where such a pattern - also catches something the build needs, re-include it with a negation - (`!docs/example.env`); deleting the pattern reopens the exposure for every - other file it covers. Fetch the standard `.dockerignore` from + therefore excludes only the copies at the repository root. `config/.env` and + `certs/server.key` still reach the context and can land in an image layer. + Such a `.dockerignore` 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. Those are `.claude`, and the repo's own + host-built binary. The binary is 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 copy of each pattern + still misses `Server.Key`. So secret names use character ranges: + `**/*.[kK][eE][yY]`, `**/*.[pP][eE][mM]`, and likewise for `.envrc` and the + extensionless SSH keys. Where such a pattern also catches something the build + needs, re-include it with a negation (`!docs/example.env`). Deleting the + pattern reopens the exposure for every other file it covers. Fetch the + standard `.dockerignore` from `https://git.eeqj.de/sneak/prompts/raw/branch/main/.dockerignore` and extend it with the repo's own artifacts. - **In-repo agent scratch belongs in both files, written to each file's own - semantics.** `.claude/` holds one worktree per in-flight agent — an entire - additional checkout of the repo — so under `COPY . .` the build context - inflates by a multiple of the repo and another session's unreviewed work can + semantics.** `.claude/` holds one worktree per in-flight agent, an entire + additional checkout of the repo. Under `COPY . .` the build context therefore + inflates by a multiple of the repo, and another session's unreviewed work can be copied into an image layer. In `.gitignore` the entry is `.claude/`, unanchored. In `.dockerignore` it is `.claude`, anchored and with **no** `**/` prefix, because the prefixed form would also delete any nested directory of - that name from the build. Anchoring carries a known gap that the canonical - `.dockerignore` states in its own comment, since consuming repos receive the - file and not the tracker: the directory is created in the agent's working - directory, so a repo running agents in subdirectories still ships - `services/api/.claude/` and must add its own anchored entry there. + that name from the build. Anchoring carries a known gap. The canonical + `.dockerignore` states the gap in its own comment, since consuming repos + receive the file and not the tracker. The gap is that the directory is created + in the agent's working directory. A repo running agents in subdirectories + therefore 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.** The version is 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 - than failing, and the `[ -n "$version" ]` line is the single place the - 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 - there because `ARG` is stage-scoped; passing `VERSION` to a repo whose + than failing. The `[ -n "$version" ]` line is the single place the fallback + is applied. That line is 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` 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 + patterns.** Plant files at the root _and_ at least two directories deep. Build a probe image that does `COPY . .`, and list what actually landed (`docker run --rm --entrypoint find IMAGE /app`). The `transferring context` size is not a substitute: a nested secret is a few bytes, and BuildKit transfers only the delta from the previous build. -- **No build artifacts in version control.** Code-derived data (compiled - bundles, minified output, generated assets) must never be committed to the - repository if it can be avoided. The build process (e.g. Dockerfile, Makefile) - should generate these at build time. Notable exception: Go protobuf generated - files (`.pb.go`) ARE committed because repos need to work with `go get`, which - downloads code but does not execute code generation. +- **No build artifacts in version control.** Code-derived data must never be + committed to the repository if it can be avoided. Code-derived data includes: + - compiled bundles + - minified output + - generated assets + + The build process (e.g. Dockerfile, Makefile) should generate these at build + time. Notable exception: Go protobuf generated files (`.pb.go`) ARE + committed because repos need to work with `go get`, which downloads code but + does not execute code generation. - Never use `git add -A` or `git add .`. Always stage files explicitly by name. @@ -416,45 +871,65 @@ style conventions are in separate documents: feature branch. - `.golangci.yml` is standardized. The vendored copy in a consuming repo must - _NEVER_ be modified by an agent: fetch it from + _NEVER_ be modified by an agent. Fetch it from `https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml` and keep it byte-identical, so that no repo can quietly loosen its own linting. Linter configuration changes are made to the canonical copy in the `prompts` repo and - reach consuming repos by re-vendoring; an agent may open a PR against + reach consuming repos by re-vendoring. An agent may open a PR against canonical, which only the user merges. One list is exempt from byte-identity, because it cannot be written once for every repo: the `deny` list of the - `test-support` depguard rule, where a repo names its own test-support packages + `test-support` depguard rule. There 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 - 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. + v2.15.0 (released 2026-10-16). The version is pinned as the digest of the lint + phase's base image, + `golangci/golangci-lint@sha256:fbfcfd12a6f8d7f1b97b3362ca80fc2a244d46863148c73cb88e7793af96c481`, + which reports `2.15.0 built with go1.27.0 from 25c5369a`. 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. 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 - `PATH` only, so on an already-provisioned machine the pin is inert and a - version bump is a silent no-op — while the Dockerfile, installing into a clean - image, gets the pinned version, so a local `make check` and `make docker` can + `PATH` only. On an already-provisioned machine the pin is then inert, and a + version bump is a silent no-op. The Dockerfile, installing into a clean image, + gets the pinned version, so a local `make check` and `make docker` can disagree about what the tool even is. The canonical form: - compares the installed version against the pin over the **whole** version - token; a parser that stops at the first `-` reports `2.12.2` for a host - running `2.12.2-rc1` and skips the install; - - treats absent, non-zero, empty or unrecognised `--version` output as a - mismatch, so the failure direction is a redundant install and never a - skipped one; - - after installing, re-resolves the binary the way callers do — `hash -r`, - then through `PATH`, not through the directory the installer wrote to — - and fails naming the resolved path, since an install that a shadowing - binary hides succeeds while changing nothing any caller sees; - - is actually called, and prints the version on both success paths: a + token. A parser that stops at the first `-` reports `2.12.2` for a host + running `2.12.2-rc1` and skips the install. + - treats `--version` output as a mismatch when it is any of these: + - absent + - non-zero + - empty + - unrecognised + + So a failure causes a redundant install, never a skipped one. + + - after installing, re-resolves the binary the way callers do, with + `hash -r` and then through `PATH`, not through the directory the installer + wrote to. A shadowing binary can hide an install, which then succeeds + while no caller sees any change. In that case it fails and names the + resolved path. + - is actually called, and prints the version on both success paths. A function defined and never invoked has the same exit status and the same empty output as one that worked. + A tool from the distribution has no pin, so `script/bootstrap` installs it + when it is missing. make, git, node and yarn are such tools. + 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). @@ -508,15 +983,21 @@ style conventions are in separate documents: - True client IP detection when behind a reverse proxy (`X-Forwarded-For`, `X-Real-IP`). The application must accept forwarded headers only from a configured set of trusted proxy - addresses — never trust `X-Forwarded-For` unconditionally. + addresses. Never trust `X-Forwarded-For` unconditionally. - **CORS:** - Authenticated endpoints must restrict `Access-Control-Allow-Origin` to an explicit allowlist of known origins. Wildcard (`*`) is acceptable only for public, unauthenticated read-only APIs. - **Error handling:** - - Internal errors must never leak stack traces, SQL queries, file paths, - or other implementation details to the client. Return generic error - messages in production; detailed errors only when `DEBUG` is enabled. + - Internal errors must never leak implementation details to the client. + Those details include: + - stack traces + - SQL queries + - file paths + + Return generic error messages in production; detailed errors only + when `DEBUG` is enabled. + - **TLS:** - Services never terminate TLS directly. They are always deployed behind a TLS-terminating reverse proxy. The service itself listens on plain @@ -528,10 +1009,17 @@ style conventions are in separate documents: still expected. When in doubt, harden. - `README.md` is the primary documentation. Required sections: - - **Description**: First line must include the project name, purpose, - category (web server, SPA, CLI tool, etc.), license, and author. Example: - "µPaaS is an MIT-licensed Go web application by @sneak that receives - git-frontend webhooks and deploys applications via Docker in realtime." + - **Description**: First line must include: + - the project name + - purpose + - category (web server, SPA, CLI tool, etc.) + - license + - author + + Example: "µPaaS is an MIT-licensed Go web application by @sneak that + receives git-frontend webhooks and deploys applications via Docker in + realtime." + - **Getting Started**: Copy-pasteable install/usage code block. - **Entrypoints**: Opens by stating that the repo adheres to the [Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all) @@ -564,13 +1052,25 @@ 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. It is the standard file from + `https://git.eeqj.de/sneak/prompts/raw/branch/main/.editorconfig`, followed by + the repo's own sections, such as one for another language it uses. The + standard file sets tabs for `Makefile` and Go files. 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` + - 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 +1101,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/script/bootstrap b/script/bootstrap new file mode 100755 index 0000000..3378340 --- /dev/null +++ b/script/bootstrap @@ -0,0 +1,104 @@ +#!/bin/sh +# script/bootstrap: install all dependencies needed to build and develop +# this repo. Idempotent: every install is guarded by a check so already +# installed tools are skipped. Base tools, go, node and yarn come from +# nix, apt, brew, or apk (detected in that order), with no version and +# no hash, because the distribution is trusted. It installs node, yarn +# and the node packages only when yarn.lock is in the tree. Assumes +# nothing is present. +set -eu + +ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" + +PKGMGR="" +SUDO="" +APT_UPDATED="" + +detect_pkgmgr() { + [ -n "$PKGMGR" ] && return 0 + if command -v nix-env >/dev/null 2>&1; then + PKGMGR="nix" + elif command -v apt-get >/dev/null 2>&1; then + PKGMGR="apt" + elif command -v brew >/dev/null 2>&1; then + PKGMGR="brew" + elif command -v apk >/dev/null 2>&1; then + PKGMGR="apk" + else + echo "bootstrap: no supported package manager (nix, apt, brew, apk)" >&2 + exit 1 + fi + if [ "$PKGMGR" = "apt" ]; then + export DEBIAN_FRONTEND=noninteractive + if [ "$(id -u)" != "0" ]; then + SUDO="sudo" + fi + fi +} + +# pkg_install +pkg_install() { + detect_pkgmgr + case "$PKGMGR" in + nix) nix-env -iA "nixpkgs.$1" ;; + apt) + # Package lists may be empty (fresh images); refresh once per run. + if [ -z "$APT_UPDATED" ]; then + $SUDO env DEBIAN_FRONTEND=noninteractive apt-get update + APT_UPDATED=1 + fi + $SUDO env DEBIAN_FRONTEND=noninteractive apt-get install -y "$2" + ;; + brew) brew install "$3" ;; + apk) apk add --no-cache "$4" ;; + esac +} + +missing() { + ! command -v "$1" >/dev/null 2>&1 +} + +# Debian and Ubuntu name the yarn command yarnpkg. +run_yarn() { + if command -v yarn >/dev/null 2>&1; then + yarn "$@" + elif command -v yarnpkg >/dev/null 2>&1; then + yarnpkg "$@" + else + echo "no yarn; run script/bootstrap first" >&2 + exit 1 + fi +} + +main() { + cd "$ROOT" + + if missing make; then pkg_install gnumake make make make; fi + if missing git; then pkg_install git git git git; fi + # Go on the host is for script/fmt and script/fmt-check (gofmt) and + # for make build; the gates compile inside the Dockerfile's phases. + if missing go; then pkg_install go golang-go go go; fi + + # A Dockerfile stage that needs no yarn copies no yarn.lock. + if [ -f yarn.lock ]; then + if missing node; then pkg_install nodejs nodejs node nodejs; fi + if missing yarn && missing yarnpkg; then + pkg_install yarn yarnpkg yarn yarn + fi + + # Debian 13 and Ubuntu 26.04 package yarn 4, which refuses a yarn 1 + # yarn.lock, or rewrites it in its own format. + yarn_version="$(run_yarn --version)" + if [ "${yarn_version%%.*}" != "1" ]; then + echo "bootstrap: yarn $yarn_version refuses this yarn 1 yarn.lock;" \ + "use a distribution that packages yarn 1" >&2 + exit 1 + fi + + run_yarn install --frozen-lockfile + fi + + echo "bootstrap complete" +} + +main "$@" diff --git a/script/check b/script/check new file mode 100755 index 0000000..92875f7 --- /dev/null +++ b/script/check @@ -0,0 +1,16 @@ +#!/bin/sh +# script/check: run all checks (test, lint, fmt-check). Our own +# extension to scripts-to-rule-them-all. test and lint are Docker +# phases; fmt-check is native, because a formatter writes the working +# tree. Must not modify any files. +set -eu + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" + +main() { + "$SCRIPT_DIR/test" + "$SCRIPT_DIR/lint" + "$SCRIPT_DIR/fmt-check" +} + +main "$@" diff --git a/script/cibuild b/script/cibuild new file mode 100755 index 0000000..2656771 --- /dev/null +++ b/script/cibuild @@ -0,0 +1,33 @@ +#!/bin/sh +# script/cibuild: run the CI build. It bootstraps first: a CI runner +# checks out and runs this and nothing else, and script/fmt-check runs +# the formatter on the host, which a pristine checkout cannot do. +# A new CACHEBUST value reruns every RUN step after the ARG CACHEBUST +# line, since a cached gate phase is a check that did not run; unlike +# --no-cache, it keeps the layers above that line and the cache mounts. +# This relies on every stage of the Dockerfile declaring ARG CACHEBUST +# before every step that lints, tests or compiles; a stage without that +# line is served from cache on an unchanged tree, with no warning. +set -eu + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" +ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)" + +main() { + cd "$ROOT" + "$SCRIPT_DIR/bootstrap" + "$SCRIPT_DIR/check" + # 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" + tag="$("$SCRIPT_DIR/projectname")" + docker build --build-arg CACHEBUST="$(date +%s)-$$" \ + --build-arg VERSION="$version" \ + -t "$tag" . +} + +main "$@" diff --git a/script/docker b/script/docker new file mode 100755 index 0000000..cc00d9c --- /dev/null +++ b/script/docker @@ -0,0 +1,30 @@ +#!/bin/sh +# script/docker: build the Docker image tagged with the project name. +# Identical in all repos; the tag comes from script/projectname. +# A new CACHEBUST value reruns every RUN step after the ARG CACHEBUST +# line, since a cached gate phase is a check that did not run; unlike +# --no-cache, it keeps the layers above that line and the cache mounts. +# This relies on every stage of the Dockerfile declaring ARG CACHEBUST +# before every step that lints, tests or compiles; a stage without that +# line is served from cache on an unchanged tree, with no warning. +set -eu + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" +ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)" + +main() { + cd "$ROOT" + # 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" + tag="$("$SCRIPT_DIR/projectname")" + docker build --build-arg CACHEBUST="$(date +%s)-$$" \ + --build-arg VERSION="$version" \ + -t "$tag" . +} + +main "$@" diff --git a/script/fmt b/script/fmt new file mode 100755 index 0000000..28ac77d --- /dev/null +++ b/script/fmt @@ -0,0 +1,29 @@ +#!/bin/sh +# script/fmt: format all files (writes): gofmt for Go, prettier for +# markdown. +set -eu + +ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" + +GO_DIRS="cmd internal pkg" + +# Debian and Ubuntu name the yarn command yarnpkg. +run_yarn() { + if command -v yarn >/dev/null 2>&1; then + yarn "$@" + elif command -v yarnpkg >/dev/null 2>&1; then + yarnpkg "$@" + else + echo "no yarn; run script/bootstrap first" >&2 + exit 1 + fi +} + +main() { + cd "$ROOT" + # shellcheck disable=SC2086 # GO_DIRS is a word list on purpose + gofmt -w $GO_DIRS + run_yarn run prettier --write '**/*.md' --tab-width 4 --prose-wrap always +} + +main "$@" diff --git a/script/fmt-check b/script/fmt-check new file mode 100755 index 0000000..998b0bf --- /dev/null +++ b/script/fmt-check @@ -0,0 +1,34 @@ +#!/bin/sh +# script/fmt-check: check formatting (read-only): gofmt for Go, prettier +# for markdown. +set -eu + +ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" + +GO_DIRS="cmd internal pkg" + +# Debian and Ubuntu name the yarn command yarnpkg. +run_yarn() { + if command -v yarn >/dev/null 2>&1; then + yarn "$@" + elif command -v yarnpkg >/dev/null 2>&1; then + yarnpkg "$@" + else + echo "no yarn; run script/bootstrap first" >&2 + exit 1 + fi +} + +main() { + cd "$ROOT" + # shellcheck disable=SC2086 # GO_DIRS is a word list on purpose + unformatted="$(gofmt -l $GO_DIRS)" + if [ -n "$unformatted" ]; then + echo "gofmt: the following files need formatting:" >&2 + echo "$unformatted" >&2 + exit 1 + fi + run_yarn run prettier --check '**/*.md' --tab-width 4 --prose-wrap always +} + +main "$@" diff --git a/script/install-precommit b/script/install-precommit new file mode 100755 index 0000000..bef6406 --- /dev/null +++ b/script/install-precommit @@ -0,0 +1,16 @@ +#!/bin/sh +# script/install-precommit: install the git pre-commit hook that runs +# script/precommit. Our own extension to scripts-to-rule-them-all. +set -eu + +ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" + +main() { + cd "$ROOT" + hook=".git/hooks/pre-commit" + printf '#!/bin/sh\nset -e\nscript/precommit\n' > .git/hooks/pre-commit + chmod +x .git/hooks/pre-commit + echo "pre-commit hook installed: runs script/precommit" +} + +main "$@" diff --git a/script/lint b/script/lint new file mode 100755 index 0000000..451a067 --- /dev/null +++ b/script/lint @@ -0,0 +1,28 @@ +#!/bin/sh +# script/lint: run the linter. Linting is a phase of the Dockerfile and +# this builds that phase alone; the linter is never installed or run on +# a developer host, where a shared result cache and a host-global lock +# make its answer untrustworthy. +# +# 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 after +# the ARG CACHEBUST line, since a cached lint layer is a lint that did +# not run; unlike --no-cache, it keeps the layers above that line and +# the cache mounts. This relies on every stage of the Dockerfile +# declaring ARG CACHEBUST before every step that lints, tests or +# compiles; a stage without that line is served from cache on an +# unchanged tree, with no warning. +# --output type=cacheonly writes no image, since nothing uses one. +set -eu + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" +ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)" + +main() { + cd "$ROOT" + docker build --build-arg CACHEBUST="$(date +%s)-$$" \ + --target lint \ + --output type=cacheonly . +} + +main "$@" diff --git a/script/precommit b/script/precommit new file mode 100755 index 0000000..9b86350 --- /dev/null +++ b/script/precommit @@ -0,0 +1,22 @@ +#!/bin/sh +# script/precommit: run by the git pre-commit hook; fails the commit if +# checks fail. Our own extension to scripts-to-rule-them-all. The Go +# extra: go.mod and go.sum must already be tidy, so a commit never +# carries a module file that go mod tidy would rewrite. +set -eu + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" +ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)" + +main() { + cd "$ROOT" + go mod tidy + if ! git diff --quiet --exit-code -- go.mod go.sum; then + echo "precommit: go mod tidy changed go.mod or go.sum;" \ + "stage them and retry" >&2 + exit 1 + fi + "$SCRIPT_DIR/check" +} + +main "$@" diff --git a/script/projectname b/script/projectname new file mode 100755 index 0000000..9b7a044 --- /dev/null +++ b/script/projectname @@ -0,0 +1,12 @@ +#!/bin/sh +# script/projectname: output the name of this project. Our own +# extension to scripts-to-rule-them-all. Other scripts that need the +# name (e.g. script/docker) call this, so they can stay identical +# across all repos. +set -eu + +main() { + echo "x" +} + +main "$@" diff --git a/script/setup b/script/setup new file mode 100755 index 0000000..4cc5b6b --- /dev/null +++ b/script/setup @@ -0,0 +1,13 @@ +#!/bin/sh +# script/setup: set up the repo for development after a fresh clone: +# installs dependencies and the git pre-commit hook. +set -eu + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" + +main() { + "$SCRIPT_DIR/bootstrap" + "$SCRIPT_DIR/install-precommit" +} + +main "$@" diff --git a/script/test b/script/test new file mode 100755 index 0000000..1622a11 --- /dev/null +++ b/script/test @@ -0,0 +1,24 @@ +#!/bin/sh +# 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, and a new CACHEBUST value because a cached test layer is a test +# that did not run; unlike --no-cache, it keeps the layers above the ARG +# CACHEBUST line and the cache mounts. This relies on every stage of the +# Dockerfile declaring ARG CACHEBUST before every step that lints, tests +# or compiles; a stage without that line is served from cache on an +# unchanged tree, with no warning. --output type=cacheonly writes no +# image, since nothing uses one. +set -eu + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" +ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)" + +main() { + cd "$ROOT" + docker build --build-arg CACHEBUST="$(date +%s)-$$" \ + --target test \ + --output type=cacheonly . +} + +main "$@"