From 5cb4f827d2efda80e55af65d1eb2b1a0b4e5f531 Mon Sep 17 00:00:00 2001 From: sneak Date: Tue, 7 Jul 2026 01:54:40 +0200 Subject: [PATCH 1/9] Adopt scripts-to-rule-them-all: script/ entrypoints, Makefile shims --- .gitea/workflows/check.yml | 2 +- Makefile | 28 +++++++++------- README.md | 34 +++++++++++++++++++ TODO.md | 2 ++ script/bootstrap | 69 ++++++++++++++++++++++++++++++++++++++ script/check | 14 ++++++++ script/cibuild | 13 +++++++ script/docker | 14 ++++++++ script/fmt | 13 +++++++ script/fmt-check | 18 ++++++++++ script/install-precommit | 16 +++++++++ script/lint | 12 +++++++ script/precommit | 19 +++++++++++ script/projectname | 12 +++++++ script/setup | 13 +++++++ script/test | 12 +++++++ 16 files changed, 277 insertions(+), 14 deletions(-) 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/.gitea/workflows/check.yml b/.gitea/workflows/check.yml index eafafa8..19a2731 100644 --- a/.gitea/workflows/check.yml +++ b/.gitea/workflows/check.yml @@ -9,4 +9,4 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - run: docker build . + - run: script/cibuild diff --git a/Makefile b/Makefile index 951120e..48ddccb 100644 --- a/Makefile +++ b/Makefile @@ -1,28 +1,30 @@ -.PHONY: test fmt fmt-check lint check docker hooks +.PHONY: bootstrap setup test fmt fmt-check lint check docker hooks default: check +bootstrap: + @script/bootstrap + +setup: + @script/setup + test: - @go test -v ./... + @script/test fmt: - goimports -l -w . - golangci-lint run --fix + @script/fmt fmt-check: - @test -z "$$(gofmt -l .)" || { echo "gofmt would reformat:"; gofmt -l .; exit 1; } + @script/fmt-check lint: - golangci-lint run + @script/lint -check: fmt-check lint test +check: + @script/check docker: - docker build --progress plain . + @script/docker hooks: - @echo "Installing git hooks..." - @mkdir -p .git/hooks - @printf '#!/bin/sh\nmake check\n' > .git/hooks/pre-commit - @chmod +x .git/hooks/pre-commit - @echo "Pre-commit hook installed." + @script/install-precommit diff --git a/README.md b/README.md index 3f6222e..08633b4 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,40 @@ func main() { } ``` +## 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. +The scripts are POSIX sh (not bash) so they run in minimal containers such as +alpine. We provide: + +- `script/bootstrap` — install all dependencies (go and golangci-lint if + missing, then `go mod download`) +- `script/setup` — set up the repo for development after a fresh clone: runs + `script/bootstrap`, then `script/install-precommit` +- `script/projectname` — output the project name (our own extension); used by + `script/docker` for the image tag +- `script/test` — run the test suite (`go test -v ./...`) +- `script/lint` — run golangci-lint +- `script/fmt` — format all files (goimports plus `golangci-lint run --fix`; + writes) +- `script/fmt-check` — check formatting (read-only); fails if `gofmt -l` + reports files +- `script/check` — run all checks: `test`, `lint`, `fmt-check` (our own + extension) +- `script/docker` — build the Docker image, tagged via `script/projectname` + (byte-identical across repos) +- `script/cibuild` — cd to the repo root and `docker build .` (what CI runs; + the image build runs the checks) +- `script/precommit` — run by the git pre-commit hook (our own extension); + runs a `go mod tidy` guard, then `script/check` +- `script/install-precommit` — installs the git pre-commit hook (our own + extension); `make hooks` shims to it + +`make hooks` installs the pre-commit hook that runs `script/precommit`. + ## License [WTFPL](./LICENSE) diff --git a/TODO.md b/TODO.md index cbd9a8d..52b2c23 100644 --- a/TODO.md +++ b/TODO.md @@ -24,6 +24,8 @@ files it depends on: .golangci.yml, REPO_POLICIES.md, .editorconfig, # Completed Steps +- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints, + Makefile shims, README Entrypoints section * 2026-02-08: fixed JSONHandler deadlock from recursive log.Println, with regression test; tagged 1.0.1 * 2024-06-14: 1.0 prep: lint and fmt enforced in Docker build, call diff --git a/script/bootstrap b/script/bootstrap new file mode 100755 index 0000000..afece87 --- /dev/null +++ b/script/bootstrap @@ -0,0 +1,69 @@ +#!/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 tooling comes from nix, apt, brew, +# or apk (detected in that order); assumes nothing is present. +set -eu + +ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" + +PKGMGR="" +SUDO="" + +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) $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 +} + +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 + + if missing go; then pkg_install go golang go go; fi + # golangci-lint is packaged in nix, brew, and apk; there is no apt + # package (on apt hosts, install it from a hash-verified GitHub + # release archive manually, never curl | sh). + if missing golangci-lint; then + pkg_install golangci-lint golangci-lint golangci-lint golangci-lint + fi + + go mod download + + echo "bootstrap complete" +} + +main "$@" diff --git a/script/check b/script/check new file mode 100755 index 0000000..3e1778c --- /dev/null +++ b/script/check @@ -0,0 +1,14 @@ +#!/bin/sh +# script/check: run all checks (test, lint, fmt-check). Our own +# extension to scripts-to-rule-them-all. 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..75cc3e6 --- /dev/null +++ b/script/cibuild @@ -0,0 +1,13 @@ +#!/bin/sh +# script/cibuild: run the CI build. The Dockerfile runs script/check, so +# a successful build implies all checks pass. +set -eu + +ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" + +main() { + cd "$ROOT" + docker build . +} + +main "$@" diff --git a/script/docker b/script/docker new file mode 100755 index 0000000..9b9ea86 --- /dev/null +++ b/script/docker @@ -0,0 +1,14 @@ +#!/bin/sh +# script/docker: build the Docker image tagged with the project name. +# Identical in all repos; the tag comes from script/projectname. +set -eu + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" +ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)" + +main() { + cd "$ROOT" + docker build -t "$("$SCRIPT_DIR/projectname")" . +} + +main "$@" diff --git a/script/fmt b/script/fmt new file mode 100755 index 0000000..216a8aa --- /dev/null +++ b/script/fmt @@ -0,0 +1,13 @@ +#!/bin/sh +# script/fmt: format all files (writes). +set -eu + +ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" + +main() { + cd "$ROOT" + goimports -l -w . + golangci-lint run --fix +} + +main "$@" diff --git a/script/fmt-check b/script/fmt-check new file mode 100755 index 0000000..6a64782 --- /dev/null +++ b/script/fmt-check @@ -0,0 +1,18 @@ +#!/bin/sh +# script/fmt-check: check formatting (read-only). Fails and lists the +# offending files if gofmt would reformat anything. +set -eu + +ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" + +main() { + cd "$ROOT" + unformatted="$(gofmt -l .)" + if [ -n "$unformatted" ]; then + echo "gofmt would reformat:" + echo "$unformatted" + exit 1 + fi +} + +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..004c999 --- /dev/null +++ b/script/lint @@ -0,0 +1,12 @@ +#!/bin/sh +# script/lint: run the linter. +set -eu + +ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" + +main() { + cd "$ROOT" + golangci-lint run +} + +main "$@" diff --git a/script/precommit b/script/precommit new file mode 100755 index 0000000..d10dc7b --- /dev/null +++ b/script/precommit @@ -0,0 +1,19 @@ +#!/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. +set -eu + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" +ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)" + +main() { + cd "$ROOT" + go mod tidy + git diff --exit-code -- go.mod go.sum || { + echo "go mod tidy changed go.mod/go.sum; stage the changes and retry" >&2 + exit 1 + } + "$SCRIPT_DIR/check" +} + +main "$@" diff --git a/script/projectname b/script/projectname new file mode 100755 index 0000000..da80c68 --- /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 "simplelog" +} + +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..98bd019 --- /dev/null +++ b/script/test @@ -0,0 +1,12 @@ +#!/bin/sh +# script/test: run the test suite. +set -eu + +ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" + +main() { + cd "$ROOT" + go test -v ./... +} + +main "$@" -- 2.54.0 From ac3031a547a2bb48f9975f0a783e5ee1f5215b14 Mon Sep 17 00:00:00 2001 From: sneak Date: Tue, 7 Jul 2026 01:55:33 +0200 Subject: [PATCH 2/9] Add vendored REPO_POLICIES.md from prompts repo --- REPO_POLICIES.md | 408 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 408 insertions(+) create mode 100644 REPO_POLICIES.md diff --git a/REPO_POLICIES.md b/REPO_POLICIES.md new file mode 100644 index 0000000..bc2f161 --- /dev/null +++ b/REPO_POLICIES.md @@ -0,0 +1,408 @@ +--- +title: Repository Policies +last_modified: 2026-07-06 +--- + +This document covers repository structure, tooling, and workflow standards. Code +style conventions are in separate documents: + +- [Code Styleguide](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE.md) + (general, bash, Docker) +- [Go](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_GO.md) +- [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) + +--- + +- Cross-project documentation (such as this file) must include + `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. + +- 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`. + +- 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 and runs `docker build .`; the Gitea workflow calls it. 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). + +- Always use Makefile targets (`make fmt`, `make test`, `make lint`, etc.) + instead of invoking the underlying tools directly. The Makefile is the single + source of truth for how these operations are run. + +- 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. + +- Every repo should have a `Dockerfile`. All Dockerfiles must run `make check` + as a build step so the build fails if the branch is not green. For non-server + repos, the Dockerfile should bring up a development environment and run + `make check`. For server repos, `make check` should run as an early build + stage before the final image is assembled. 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 so the bootstrap + layer stays cached until dependencies change. + +- **Dockerfiles must use a separate lint stage for fail-fast feedback.** Go + repos use a multistage build where linting runs in an independent stage based + on the `golangci/golangci-lint` image (pinned by hash). This stage runs + `make fmt-check` and `make lint` before the full build begins. The build stage + then declares an explicit dependency on the lint stage via + `COPY --from=lint /src/go.sum /dev/null`, which forces BuildKit to complete + linting before proceeding to compilation and tests. This ensures lint failures + surface in seconds rather than minutes, without blocking on dependency + download or compilation in the build stage. + + The standard pattern for a Go repo Dockerfile is: + + ```dockerfile + # Lint stage — fast feedback on formatting and lint issues + # 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 + COPY . . + RUN make fmt-check + RUN make lint + + # Build stage + # golang:1.x-alpine, YYYY-MM-DD + FROM golang@sha256:... AS builder + WORKDIR /src + + # Force BuildKit to run the lint stage before proceeding + COPY --from=lint /src/go.sum /dev/null + + COPY go.mod go.sum ./ + RUN go mod download + COPY . . + RUN make test + + ARG VERSION=dev + RUN CGO_ENABLED=0 go build -trimpath \ + -ldflags="-s -w -X main.Version=${VERSION}" \ + -o /app ./cmd/app/ + + # Runtime stage + FROM alpine@sha256:... + COPY --from=builder /app /usr/local/bin/app + ENTRYPOINT ["app"] + ``` + + Key points: + - The lint stage uses the `golangci/golangci-lint` image directly (it + includes both Go and the linter), so there is no need to install the + linter separately. + - `COPY --from=lint /src/go.sum /dev/null` is a no-op file copy that creates + a stage dependency. BuildKit runs stages in parallel by default; without + this line, the build stage would not wait for lint to finish and a lint + failure might not fail the overall build. + - If the project uses `//go:embed` directives that reference build artifacts + (e.g. a web frontend compiled in a separate stage), the lint stage must + create placeholder files so the embed directives resolve. Example: + `RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css`. + The lint stage should not depend on the actual build output — it exists to + fail fast. + - If the project requires CGO or system libraries for linting (e.g. + `vips-dev`), install them in the lint stage with `apk add`. + - The build stage runs `make test` after compilation setup. Tests run in the + build stage, not the lint stage, because they may require compiled + artifacts or heavier dependencies. + +- Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that + runs `script/cibuild` (which runs `docker build .`) on push. Since the + Dockerfile already runs `make check`, a successful build implies all checks + pass. + +- 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`. + +- 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. + +- 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 + `make test` to be a no-op. + +- `make test` must complete in under 20 seconds. Add a 30-second timeout in the + Makefile. + +- **`make test` 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 general shell pattern: + + ```makefile + test: + @ || \ + { echo "--- Rerunning with -v for details ---"; \ + ; exit 1; } + ``` + + Go example: + + ```makefile + test: + @go test -timeout 30s -race -cover ./... || \ + { echo "--- Rerunning with -v for details ---"; \ + go test -timeout 30s -race -v ./...; exit 1; } + ``` + + Python example: + + ```makefile + test: + @python -m pytest || \ + { echo "--- Rerunning with -v for details ---"; \ + python -m pytest -v; exit 1; } + ``` + + 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. + +- `make check` must not modify any files in the repo. Tests may use temporary + directories. + +- `main` must always pass `make check`, no exceptions. + +- 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`, `*~`), 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. + +- **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. + +- Never use `git add -A` or `git add .`. Always stage files explicitly by name. + +- Never force-push to `main`. + +- Make all changes on a feature branch. You can do whatever you want on a + feature branch. + +- `.golangci.yml` is standardized and must _NEVER_ be modified by an agent, only + manually by the user. Fetch from + `https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml`. + +- When pinning images or packages by hash, add a comment above the reference + with the version and date (YYYY-MM-DD). + +- Use `yarn`, not `npm`. + +- Write all dates as YYYY-MM-DD (ISO 8601). + +- Simple projects should be configured with environment variables. + +- Dockerized web services listen on port 8080 by default, overridable with + `PORT`. + +- **HTTP/web services must be hardened for production internet exposure before + tagging 1.0.** This means full compliance with security best practices + including, without limitation, all of the following: + - **Security headers** on every response: + - `Strict-Transport-Security` (HSTS) with `max-age` of at least one year + and `includeSubDomains`. + - `Content-Security-Policy` (CSP) with a restrictive default policy + (`default-src 'self'` as a baseline, tightened per-resource as + needed). Never use `unsafe-inline` or `unsafe-eval` unless + unavoidable, and document the reason. + - `X-Frame-Options: DENY` (or `SAMEORIGIN` if framing is required). + Prefer the `frame-ancestors` CSP directive as the primary control. + - `X-Content-Type-Options: nosniff`. + - `Referrer-Policy: strict-origin-when-cross-origin` (or stricter). + - `Permissions-Policy` restricting access to browser features the + application does not use (camera, microphone, geolocation, etc.). + - **Request and response limits:** + - Maximum request body size enforced on all endpoints (e.g. Go + `http.MaxBytesReader`). Choose a sane default per-route; never accept + unbounded input. + - Maximum response body size where applicable (e.g. paginated APIs). + - `ReadTimeout` and `ReadHeaderTimeout` on the `http.Server` to defend + against slowloris attacks. + - `WriteTimeout` on the `http.Server`. + - `IdleTimeout` on the `http.Server`. + - Per-handler execution time limits via `context.WithTimeout` or + chi/stdlib `middleware.Timeout`. + - **Authentication and session security:** + - Rate limiting on password-based authentication endpoints. API keys are + high-entropy and not susceptible to brute force, so they are exempt. + - CSRF tokens on all state-mutating HTML forms. API endpoints + authenticated via `Authorization` header (Bearer token, API key) are + exempt because the browser does not attach these automatically. + - Passwords stored using bcrypt, scrypt, or argon2 — never plain-text, + MD5, or SHA. + - Session cookies set with `HttpOnly`, `Secure`, and `SameSite=Lax` (or + `Strict`) attributes. + - **Reverse proxy awareness:** + - 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. + - **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. + - **TLS:** + - Services never terminate TLS directly. They are always deployed behind + a TLS-terminating reverse proxy. The service itself listens on plain + HTTP. However, HSTS headers and `Secure` cookie flags must still be + set by the application so that the browser enforces HTTPS end-to-end. + + This list is non-exhaustive. Apply defense-in-depth: if a standard security + hardening measure exists for HTTP services and is not listed here, it is + 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." + - **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) + standard (with that link), then documents each provided `script/` + entrypoint and its purpose. + - **Rationale**: Why does this exist? + - **Design**: How is the program structured? + - **TODO**: Update meticulously, even between commits. When planning, put + the todo list in the README so a new agent can pick up where the last one + left off. + - **License**: MIT, GPL, or WTFPL. Ask the user for new projects. Include a + `LICENSE` file in the repo root and a License section in the README. + - **Author**: [@sneak](https://sneak.berlin). + +- First commit of a new repo should contain only `README.md`. + +- Go module root: `sneak.berlin/go/`. Always run `go mod tidy` before + committing. + +- Use SemVer. + +- Database migrations live in `internal/db/migrations/` and must be embedded in + the binary. + - `000_migration.sql` — contains ONLY the creation of the migrations + tracking table itself. Nothing else. + - `001_schema.sql` — the full application schema. + - **Pre-1.0.0:** never add additional migration files (002, 003, etc.). + There is no installed base to migrate. Edit `001_schema.sql` directly. + - **Post-1.0.0:** add new numbered migration files for each schema change. + Never edit existing migrations after release. + +- All repos should have an `.editorconfig` enforcing the project's indentation + settings. + +- 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: + - `bin/` — executable scripts and tools + - `cmd/` — Go command entrypoints + - `configs/` — configuration templates and examples + - `deploy/` — deployment manifests (k8s, compose, terraform) + - `docs/` — documentation and markdown (README.md stays in root) + - `internal/` — Go internal packages + - `internal/db/migrations/` — database migrations + - `pkg/` — Go library packages + - `share/` — systemd units, data files + - `static/` — static assets (images, fonts, etc.) + - `web/` — web frontend source + +- When setting up a new repo, files from the `prompts` repo may be used as + templates. Fetch them from + `https://git.eeqj.de/sneak/prompts/raw/branch/main/`. + +- New repos must contain at minimum: + - `README.md`, `.git`, `.gitignore`, `.editorconfig` + - `LICENSE`, `REPO_POLICIES.md` (copy from the `prompts` repo) + - `Makefile` + - `script/` entrypoints (`bootstrap`, `setup`, `projectname`, `test`, + `lint`, `fmt`, `fmt-check`, `check`, `docker`, `cibuild`, `precommit`, + `install-precommit`) + - `Dockerfile`, `.dockerignore` + - `.gitea/workflows/check.yml` + - Go: `go.mod`, `go.sum`, `.golangci.yml` + - JS: `package.json`, `yarn.lock`, `.prettierrc`, `.prettierignore` + - Python: `pyproject.toml` -- 2.54.0 From 151dd42b4b1072d14c7fab8b1272ac01b8bc247e Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Tue, 6 Oct 2026 08:45:17 +0200 Subject: [PATCH 3/9] Return sink write errors from every handler (closes #22) The console and JSON handlers threw away the error from their write to stdout, so a lost log line looked delivered. They now return it, wrapped. The webhook handler also returns an error for a status outside 2xx, and no longer follows redirects, which could resend the request without the record. The multiplex handler passes the record to every handler and returns their errors joined with errors.Join instead of stopping at the first. Both stdout handlers gain an unexported writer, nil meaning os.Stdout at write time, so tests can supply a sink that fails. The README says what each handler returns and that the slog.Logger methods discard a handler's error. Model: opus-5-5 --- README.md | 25 +++++ TODO.md | 4 + console_handler.go | 22 ++++- handler_errors_internal_test.go | 162 ++++++++++++++++++++++++++++++++ json_handler.go | 20 +++- simplelog.go | 12 ++- webhook_handler.go | 30 +++++- 7 files changed, 259 insertions(+), 16 deletions(-) create mode 100644 handler_errors_internal_test.go diff --git a/README.md b/README.md index 4cd1a4f..9a07db5 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,9 @@ Released v1.0.0 2024-06-14. Works as intended. No known bugs. in console output they are appended as `key=value` pairs, with grouped keys written as `group.key=value`. See [Attribute output](#attribute-output) for the details worth knowing +- reports a record that could not be delivered as an error from the + handler's `Handle` method. See + [When delivery fails](#when-delivery-fails) for how to receive it ## Planned Features @@ -104,6 +107,28 @@ ignored, an empty group is elided along with its key, a group with an empty key is inlined into its parent, and `WithGroup("")` returns the handler unchanged. +## When delivery fails + +Every handler returns an error from `Handle` when it could not deliver +the record: + +- `ConsoleHandler` and `JSONHandler` return the error from their write to + stdout, wrapped, so `errors.Is` still matches the original +- `WebhookHandler` returns an error when the request fails, and also when + the server answers with a status outside 2xx, a redirect included, + since it does not follow redirects +- `MultiplexHandler`, which simplelog installs as the default, passes the + record to every handler it holds even after one of them fails, then + returns all their errors joined with `errors.Join` (nil if none failed) + +`slog.Info`, `slog.Error` and the other `slog.Logger` methods throw that +error away. That is how `log/slog` works, and simplelog cannot change it. +To find out whether a record was delivered, build a `slog.Record` and pass +it to the handler yourself, for example +`slog.Default().Handler().Handle(ctx, record)`, then check the error it +returns. A record passed to `Handle` directly gets the wrong file and +line in console output. + ## Entrypoints This repository adheres to the diff --git a/TODO.md b/TODO.md index 7cfdd45..a5344d9 100644 --- a/TODO.md +++ b/TODO.md @@ -24,6 +24,10 @@ files it depends on: .golangci.yml, REPO_POLICIES.md, .editorconfig, # Completed Steps +* 2026-10-06: every handler now returns a failed delivery from `Handle` + instead of discarding it: console and JSON return the stdout write + error, the webhook also fails on a non-2xx answer, and the multiplex + delivers to every handler and returns their errors joined * 2026-08-10: fixed every handler discarding slog attributes: console, JSON and webhook handlers now emit record attributes, accumulate WithAttrs without mutating the receiver, and honour WithGroup; diff --git a/console_handler.go b/console_handler.go index f26c209..9aa303c 100644 --- a/console_handler.go +++ b/console_handler.go @@ -3,6 +3,7 @@ package simplelog import ( "context" "fmt" + "io" "log/slog" "os" "runtime" @@ -17,6 +18,9 @@ const callerSkipFrames = 4 // ConsoleHandler writes human-readable, colored log lines to stdout. type ConsoleHandler struct { + // out is where records are written. Nil means os.Stdout, looked up on + // every write so that a reassigned os.Stdout is followed. + out io.Writer attrs handlerAttrs } @@ -27,7 +31,7 @@ func NewConsoleHandler() *ConsoleHandler { // Handle writes the record to stdout as a colored, timestamped line // including the caller file and line, followed by the attributes as -// key=value pairs. +// key=value pairs. A failed write is returned as an error. func (c *ConsoleHandler) Handle( _ context.Context, record slog.Record, @@ -56,8 +60,13 @@ func (c *ConsoleHandler) Handle( line = 0 } - _, _ = fmt.Fprintln( - os.Stdout, + out := c.out + if out == nil { + out = os.Stdout + } + + _, err := fmt.Fprintln( + out, colorFunc( "%s [%s] %s:%d: %s%s", timestamp, @@ -68,6 +77,9 @@ func (c *ConsoleHandler) Handle( attrsToText(c.attrs.forRecord(record)), ), ) + if err != nil { + return fmt.Errorf("error writing log record: %w", err) + } return nil } @@ -88,7 +100,7 @@ func (c *ConsoleHandler) WithAttrs(attrs []slog.Attr) slog.Handler { return c } - return &ConsoleHandler{attrs: c.attrs.withAttrs(attrs)} + return &ConsoleHandler{out: c.out, attrs: c.attrs.withAttrs(attrs)} } // WithGroup returns a new handler that qualifies later attributes with @@ -98,5 +110,5 @@ func (c *ConsoleHandler) WithGroup(name string) slog.Handler { return c } - return &ConsoleHandler{attrs: c.attrs.withGroup(name)} + return &ConsoleHandler{out: c.out, attrs: c.attrs.withGroup(name)} } diff --git a/handler_errors_internal_test.go b/handler_errors_internal_test.go new file mode 100644 index 0000000..5c4af86 --- /dev/null +++ b/handler_errors_internal_test.go @@ -0,0 +1,162 @@ +package simplelog + +import ( + "bytes" + "context" + "errors" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +// These tests sit inside the package so they can point a handler at a sink +// that fails, which callers outside the package cannot do. + +// errSinkFailed is what failingWriter returns from every write. +var errSinkFailed = errors.New("sink failed") + +// failingWriter is a sink whose every write fails. +type failingWriter struct{} + +func (failingWriter) Write(_ []byte) (int, error) { + return 0, errSinkFailed +} + +func errorTestRecord() slog.Record { + return slog.NewRecord(time.Now(), slog.LevelInfo, "casting", 0) +} + +// The handler is derived with WithAttrs and WithGroup, so the test also +// fails if either of them drops the sink. +func TestJSONHandlerReturnsWriteError(t *testing.T) { + t.Parallel() + + handler := (&JSONHandler{out: failingWriter{}}). + WithAttrs([]slog.Attr{slog.String("service", "cattbox")}). + WithGroup("cast") + + err := handler.Handle(context.Background(), errorTestRecord()) + if !errors.Is(err, errSinkFailed) { + t.Fatalf("Handle returned %v, want the sink's error", err) + } +} + +func TestConsoleHandlerReturnsWriteError(t *testing.T) { + t.Parallel() + + handler := (&ConsoleHandler{out: failingWriter{}}). + WithAttrs([]slog.Attr{slog.String("service", "cattbox")}). + WithGroup("cast") + + err := handler.Handle(context.Background(), errorTestRecord()) + if !errors.Is(err, errSinkFailed) { + t.Fatalf("Handle returned %v, want the sink's error", err) + } +} + +// A failing handler must not stop the record reaching the handlers after +// it, and its error must still reach the caller. +func TestMultiplexHandlerDeliversPastFailingHandler(t *testing.T) { + t.Parallel() + + var delivered bytes.Buffer + + handler := &MultiplexHandler{handlers: []ExtendedHandler{ + &JSONHandler{out: failingWriter{}}, + &JSONHandler{out: &delivered}, + }} + + err := handler.Handle(context.Background(), errorTestRecord()) + if !errors.Is(err, errSinkFailed) { + t.Fatalf("Handle returned %v, want the failing handler's error", err) + } + + if !strings.Contains(delivered.String(), `"Message":"casting"`) { + t.Fatalf( + "second handler did not receive the record: %q", + delivered.String(), + ) + } +} + +// When more than one handler fails, the caller gets every failure, not +// only the first. +func TestMultiplexHandlerReturnsEveryFailure(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc( + func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + }, + )) + defer server.Close() + + webhook, err := NewWebhookHandler(server.URL) + if err != nil { + t.Fatalf("NewWebhookHandler: %v", err) + } + + handler := &MultiplexHandler{handlers: []ExtendedHandler{ + &JSONHandler{out: failingWriter{}}, + webhook, + }} + + err = handler.Handle(context.Background(), errorTestRecord()) + if !errors.Is(err, errSinkFailed) { + t.Fatalf("Handle returned %v, want the JSON handler's error", err) + } + + if !errors.Is(err, errWebhookStatus) { + t.Fatalf("Handle returned %v, want the webhook's error", err) + } +} + +func TestWebhookHandlerReturnsErrorOnServerError(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc( + func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + }, + )) + defer server.Close() + + handler, err := NewWebhookHandler(server.URL) + if err != nil { + t.Fatalf("NewWebhookHandler: %v", err) + } + + err = handler.Handle(context.Background(), errorTestRecord()) + if !errors.Is(err, errWebhookStatus) { + t.Fatalf("Handle returned %v, want an error for the 500 answer", err) + } +} + +// Following the redirect would resend the request as a GET without the +// record, and the GET is answered with 200, so only an error for the +// redirect itself tells the caller the record was lost. +func TestWebhookHandlerReturnsErrorOnRedirect(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/moved" { + http.Redirect(w, r, "/moved", http.StatusFound) + } + }, + )) + defer server.Close() + + handler, err := NewWebhookHandler(server.URL) + if err != nil { + t.Fatalf("NewWebhookHandler: %v", err) + } + + err = handler.Handle(context.Background(), errorTestRecord()) + if !errors.Is(err, errWebhookStatus) { + t.Fatalf("Handle returned %v, want an error for the redirect", err) + } +} diff --git a/json_handler.go b/json_handler.go index 093e503..e0f2a1c 100644 --- a/json_handler.go +++ b/json_handler.go @@ -4,12 +4,16 @@ import ( "context" "encoding/json" "fmt" + "io" "log/slog" "os" ) // JSONHandler writes each log record to stdout as a JSON document. type JSONHandler struct { + // out is where records are written. Nil means os.Stdout, looked up on + // every write so that a reassigned os.Stdout is followed. + out io.Writer attrs handlerAttrs } @@ -19,14 +23,22 @@ func NewJSONHandler() *JSONHandler { } // Handle marshals the record, with its attributes, to one JSON object and -// writes it to stdout. +// writes it to stdout. A failed write is returned as an error. func (j *JSONHandler) Handle(_ context.Context, record slog.Record) error { jsonData, err := json.Marshal(recordToMap(record, j.attrs)) if err != nil { return err } - _, _ = fmt.Fprintln(os.Stdout, string(jsonData)) + out := j.out + if out == nil { + out = os.Stdout + } + + _, err = fmt.Fprintln(out, string(jsonData)) + if err != nil { + return fmt.Errorf("error writing log record: %w", err) + } return nil } @@ -44,7 +56,7 @@ func (j *JSONHandler) WithAttrs(attrs []slog.Attr) slog.Handler { return j } - return &JSONHandler{attrs: j.attrs.withAttrs(attrs)} + return &JSONHandler{out: j.out, attrs: j.attrs.withAttrs(attrs)} } // WithGroup returns a new handler that nests later attributes in an @@ -54,5 +66,5 @@ func (j *JSONHandler) WithGroup(name string) slog.Handler { return j } - return &JSONHandler{attrs: j.attrs.withGroup(name)} + return &JSONHandler{out: j.out, attrs: j.attrs.withGroup(name)} } diff --git a/simplelog.go b/simplelog.go index 8fe77dc..9012dff 100644 --- a/simplelog.go +++ b/simplelog.go @@ -7,6 +7,7 @@ package simplelog import ( "context" "encoding/json" + "errors" "log" "log/slog" "os" @@ -62,20 +63,23 @@ func NewMultiplexHandler() slog.Handler { return cl } -// Handle forwards the record to every underlying handler, stopping at -// the first error. +// Handle forwards the record to every underlying handler, including the +// ones after a handler that fails. It returns the failures joined with +// errors.Join, or nil when every handler succeeded. func (cl *MultiplexHandler) Handle( ctx context.Context, record slog.Record, ) error { + var errs []error + for _, handler := range cl.handlers { err := handler.Handle(ctx, record) if err != nil { - return err + errs = append(errs, err) } } - return nil + return errors.Join(errs...) } // Enabled reports whether the handler processes records at the given diff --git a/webhook_handler.go b/webhook_handler.go index d73f9ad..a7cf9b5 100644 --- a/webhook_handler.go +++ b/webhook_handler.go @@ -4,16 +4,22 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "log/slog" "net/http" "net/url" ) +// errWebhookStatus is returned when the webhook answers with a status +// outside 2xx. +var errWebhookStatus = errors.New("webhook did not accept the record") + // WebhookHandler POSTs each log record as JSON to a configured webhook // URL. type WebhookHandler struct { webhookURL string + client *http.Client attrs handlerAttrs } @@ -25,7 +31,17 @@ func NewWebhookHandler(webhookURL string) (*WebhookHandler, error) { return nil, fmt.Errorf("invalid webhook URL: %w", err) } - return &WebhookHandler{webhookURL: webhookURL}, nil + return &WebhookHandler{ + webhookURL: webhookURL, + client: &http.Client{ + // Following a redirect can resend the request as a GET + // without the record, so Handle gets the redirect answer + // itself and returns it as an error. + CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }, + }, + }, nil } // Enabled reports whether the handler processes records at the given @@ -43,6 +59,7 @@ func (w *WebhookHandler) WithAttrs(attrs []slog.Attr) slog.Handler { return &WebhookHandler{ webhookURL: w.webhookURL, + client: w.client, attrs: w.attrs.withAttrs(attrs), } } @@ -56,12 +73,14 @@ func (w *WebhookHandler) WithGroup(name string) slog.Handler { return &WebhookHandler{ webhookURL: w.webhookURL, + client: w.client, attrs: w.attrs.withGroup(name), } } // Handle marshals the record, with its attributes, to one JSON object and -// POSTs it to the webhook URL. +// POSTs it to the webhook URL. It returns an error when the request fails +// or the server answers with a status outside 2xx. func (w *WebhookHandler) Handle(ctx context.Context, record slog.Record) error { jsonData, err := json.Marshal(recordToMap(record, w.attrs)) if err != nil { @@ -80,12 +99,17 @@ func (w *WebhookHandler) Handle(ctx context.Context, record slog.Record) error { request.Header.Set("Content-Type", "application/json") - response, err := http.DefaultClient.Do(request) + response, err := w.client.Do(request) if err != nil { return err } defer func() { _ = response.Body.Close() }() + if response.StatusCode < http.StatusOK || + response.StatusCode >= http.StatusMultipleChoices { + return fmt.Errorf("%w: %s", errWebhookStatus, response.Status) + } + return nil } -- 2.54.0 From 8e53530434cc4c2ce1d727eac481a4685be2bdf4 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Tue, 6 Oct 2026 09:41:32 +0200 Subject: [PATCH 4/9] Run the linter only in Docker (closes #20) script/lint now builds only the lint stage of the Dockerfile, without the build cache, so every run executes the linter; the image is tagged simplelog-lint. The lint stage calls golangci-lint directly, since make lint is itself a docker build of that stage. script/cibuild and script/docker also build without the cache, so their check steps always run. script/fmt no longer runs golangci-lint --fix, and script/bootstrap no longer installs it. golangci-lint config verify is left out, on the owner's ruling. The README, TODO.md and the script/cibuild comment say what now runs. Model: opus-5-5 --- Dockerfile | 3 ++- README.md | 23 ++++++++++++++--------- TODO.md | 7 ++++--- script/bootstrap | 7 +------ script/cibuild | 10 ++++++---- script/docker | 13 ++++++++++++- script/fmt | 1 - script/lint | 17 ++++++++++++++--- 8 files changed, 53 insertions(+), 28 deletions(-) diff --git a/Dockerfile b/Dockerfile index 31c56bf..d7d21fe 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,7 +6,8 @@ COPY go.mod go.sum ./ RUN go mod download COPY . . RUN make fmt-check -RUN make lint +# Called directly: make lint is itself a docker build of this stage. +RUN golangci-lint run --config .golangci.yml ./... # Test stage: run full test suite # golang 1.22.12 (2025-02-04) diff --git a/README.md b/README.md index 9a07db5..5d0d7f5 100644 --- a/README.md +++ b/README.md @@ -138,24 +138,29 @@ development workflow, and the Makefile targets are thin shims that call them. The scripts are POSIX sh (not bash) so they run in minimal containers such as alpine. We provide: -- `script/bootstrap` — install all dependencies (go and golangci-lint if - missing, then `go mod download`) +- `script/bootstrap` — install all dependencies (go if missing, then + `go mod download`); golangci-lint is not installed, since it runs only in + Docker - `script/setup` — set up the repo for development after a fresh clone: runs `script/bootstrap`, then `script/install-precommit` - `script/projectname` — output the project name (our own extension); used by `script/docker` for the image tag - `script/test` — run the test suite (`go test -v ./...`) -- `script/lint` — run golangci-lint -- `script/fmt` — format all files (goimports plus `golangci-lint run --fix`; - writes) +- `script/lint` — run golangci-lint in Docker by building only the `lint` + stage of the `Dockerfile` (which also runs the format check), without the + build cache, so every run lints; the image is tagged `simplelog-lint` +- `script/fmt` — format all files with goimports (writes) - `script/fmt-check` — check formatting (read-only); fails if `gofmt -l` reports files - `script/check` — run all checks: `test`, `lint`, `fmt-check` (our own extension) -- `script/docker` — build the Docker image, tagged via `script/projectname` - (byte-identical across repos) -- `script/cibuild` — cd to the repo root and `docker build .` (what CI runs; - the image build runs the checks) +- `script/docker` — build the Docker image without the build cache, tagged + via `script/projectname` (byte-identical across repos); it also passes the + output of `git describe` as the `VERSION` build argument, which is ignored + because this `Dockerfile` does not declare it +- `script/cibuild` — cd to the repo root and build the Docker image without the + build cache, tagged via `script/projectname` (what CI runs; the image build + runs the checks) - `script/precommit` — run by the git pre-commit hook (our own extension); runs a `go mod tidy` guard, then `script/check` - `script/install-precommit` — installs the git pre-commit hook (our own diff --git a/TODO.md b/TODO.md index a5344d9..4a3b4ec 100644 --- a/TODO.md +++ b/TODO.md @@ -24,6 +24,10 @@ files it depends on: .golangci.yml, REPO_POLICIES.md, .editorconfig, # Completed Steps +* 2026-10-06: the linter runs only in Docker: `script/lint` builds the + `lint` stage of the `Dockerfile`, every `docker build` in `script/` + runs without the build cache, and `script/bootstrap` no longer + installs golangci-lint * 2026-10-06: every handler now returns a failed delivery from `Handle` instead of discarding it: console and JSON return the stdout write error, the webhook also fails on a non-2xx answer, and the multiplex @@ -51,9 +55,6 @@ files it depends on: .golangci.yml, REPO_POLICIES.md, .editorconfig, # Future Steps -* Rewrite the Dockerfile to run make check with sha256-pinned base - images (currently golangci/golangci-lint:latest and golang:1.22, - unpinned, duplicating Makefile logic instead of calling it) * Restructure README.md into the standard sections: Description, Getting Started, Rationale, Design, TODO, License, Author * Delete the old plain TODO file once this TODO.md lands diff --git a/script/bootstrap b/script/bootstrap index afece87..d0f3814 100755 --- a/script/bootstrap +++ b/script/bootstrap @@ -54,12 +54,7 @@ main() { if missing git; then pkg_install git git git git; fi if missing go; then pkg_install go golang go go; fi - # golangci-lint is packaged in nix, brew, and apk; there is no apt - # package (on apt hosts, install it from a hash-verified GitHub - # release archive manually, never curl | sh). - if missing golangci-lint; then - pkg_install golangci-lint golangci-lint golangci-lint golangci-lint - fi + # golangci-lint is not installed: it runs only in docker (script/lint). go mod download diff --git a/script/cibuild b/script/cibuild index 75cc3e6..8b13f21 100755 --- a/script/cibuild +++ b/script/cibuild @@ -1,13 +1,15 @@ #!/bin/sh -# script/cibuild: run the CI build. The Dockerfile runs script/check, so -# a successful build implies all checks pass. +# script/cibuild: run the CI build. The Dockerfile runs the format check, +# the linter and the tests, so a successful build means they all pass. +# --no-cache because a cached check step is a check that did not run. set -eu -ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" +ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)" main() { cd "$ROOT" - docker build . + docker build --no-cache -t "$("$SCRIPT_DIR/projectname")" . } main "$@" diff --git a/script/docker b/script/docker index 9b9ea86..c4688e8 100755 --- a/script/docker +++ b/script/docker @@ -1,6 +1,8 @@ #!/bin/sh # script/docker: build the Docker image tagged with the project name. # Identical in all repos; the tag comes from script/projectname. +# --no-cache because the gate phases the final stage depends on are RUN +# steps, and a cached one is a check that did not run. set -eu SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" @@ -8,7 +10,16 @@ ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)" main() { cd "$ROOT" - docker build -t "$("$SCRIPT_DIR/projectname")" . + # Own line: a failing command substitution inside an argument does + # not trip `set -e`, so the inline form degrades silently to an + # empty constant. VERSION is computed here because .dockerignore + # excludes .git, so `git describe` in a build stage yields an empty + # version without failing. + version="$(git describe --tags --always --dirty 2>/dev/null || true)" + [ -n "$version" ] || version="unknown" + docker build --no-cache \ + --build-arg VERSION="$version" \ + -t "$("$SCRIPT_DIR/projectname")" . } main "$@" diff --git a/script/fmt b/script/fmt index 216a8aa..64817df 100755 --- a/script/fmt +++ b/script/fmt @@ -7,7 +7,6 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" main() { cd "$ROOT" goimports -l -w . - golangci-lint run --fix } main "$@" diff --git a/script/lint b/script/lint index 004c999..2d8b075 100755 --- a/script/lint +++ b/script/lint @@ -1,12 +1,23 @@ #!/bin/sh -# script/lint: run the linter. +# 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. --no-cache because a cached lint layer is a lint +# that did not run. The tag makes each build replace the previous image +# instead of leaving a dangling one behind. set -eu -ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" +ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)" main() { cd "$ROOT" - golangci-lint run + docker build --no-cache \ + --target lint \ + -t "$("$SCRIPT_DIR/projectname")-lint" . } main "$@" -- 2.54.0 From 9b3d7326ce30a4984db67dfe2709801fd007457a Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Tue, 6 Oct 2026 13:48:23 +0200 Subject: [PATCH 5/9] Run the tests under the race detector, in Docker (closes #23) script/test is now the standard script: it builds only the test stage of the Dockerfile, without the build cache, so tests no longer run on the host. That stage calls go test -race -cover directly, with a verbose rerun on failure, on the Debian-based golang image, which has the C toolchain -race needs. The test stage no longer copies from the lint stage, so script/test builds the tests alone. A new final stage copies one file from each of the two stages; that is what makes a plain docker build, and so script/cibuild, run both. The README entry for script/test and TODO.md say what now runs. Model: opus-5-5 --- Dockerfile | 17 ++++++++++++----- README.md | 4 +++- TODO.md | 4 ++++ script/test | 13 ++++++++++--- 4 files changed, 29 insertions(+), 9 deletions(-) diff --git a/Dockerfile b/Dockerfile index d7d21fe..61f5e60 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,13 +9,20 @@ RUN make fmt-check # Called directly: make lint is itself a docker build of this stage. RUN golangci-lint run --config .golangci.yml ./... -# Test stage: run full test suite -# golang 1.22.12 (2025-02-04) +# Test stage: run full test suite under the race detector +# golang 1.22.12 (Debian-based; -race needs its C toolchain), 2025-02-04 FROM golang@sha256:1cf6c45ba39db9fd6db16922041d074a63c935556a05c5ccb62d181034df7f02 AS test -# Depend on lint stage so both stages always run -COPY --from=lint /src/go.sum /dev/null WORKDIR /src COPY go.mod go.sum ./ RUN go mod download COPY . . -RUN make test +# Called directly: make test is itself a docker build of this stage. +RUN go test -timeout 90s -race -cover ./... || \ + { echo "--- Rerunning with -v for details ---"; \ + go test -timeout 90s -race -v ./...; exit 1; } + +# Final stage: the copies make a plain docker build run both stages above. +# golang 1.22.12 (2025-02-04) +FROM golang@sha256:1cf6c45ba39db9fd6db16922041d074a63c935556a05c5ccb62d181034df7f02 +COPY --from=lint /src/go.sum /dev/null +COPY --from=test /src/go.sum /dev/null diff --git a/README.md b/README.md index 5d0d7f5..d9aa4b8 100644 --- a/README.md +++ b/README.md @@ -145,7 +145,9 @@ alpine. We provide: `script/bootstrap`, then `script/install-precommit` - `script/projectname` — output the project name (our own extension); used by `script/docker` for the image tag -- `script/test` — run the test suite (`go test -v ./...`) +- `script/test` — run the test suite under the race detector in Docker by + building only the `test` stage of the `Dockerfile`, without the build cache, + so every run tests; the image is tagged `simplelog-test` - `script/lint` — run golangci-lint in Docker by building only the `lint` stage of the `Dockerfile` (which also runs the format check), without the build cache, so every run lints; the image is tagged `simplelog-lint` diff --git a/TODO.md b/TODO.md index 4a3b4ec..b16ca6a 100644 --- a/TODO.md +++ b/TODO.md @@ -24,6 +24,10 @@ files it depends on: .golangci.yml, REPO_POLICIES.md, .editorconfig, # Completed Steps +* 2026-10-06: the tests run under the race detector, in Docker: + `script/test` builds the `test` stage of the `Dockerfile`, which runs + `go test -race`, and a new final stage makes a plain `docker build` + run both the `lint` and `test` stages * 2026-10-06: the linter runs only in Docker: `script/lint` builds the `lint` stage of the `Dockerfile`, every `docker build` in `script/` runs without the build cache, and `script/bootstrap` no longer diff --git a/script/test b/script/test index 98bd019..cd239f2 100755 --- a/script/test +++ b/script/test @@ -1,12 +1,19 @@ #!/bin/sh -# script/test: run the test suite. +# script/test: run the test suite. Testing is a phase of the Dockerfile +# and this builds that phase alone, on the same terms as script/lint: +# --target because a phase that is not the last stage is built only when +# named, --no-cache because a cached test layer is a test that did not +# run, and a tag so each build replaces the previous image. set -eu -ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" +ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)" main() { cd "$ROOT" - go test -v ./... + docker build --no-cache \ + --target test \ + -t "$("$SCRIPT_DIR/projectname")-test" . } main "$@" -- 2.54.0 From 16fc20b81c200e4e462f9692b1089984f5732058 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Tue, 6 Oct 2026 14:34:48 +0200 Subject: [PATCH 6/9] Take the console location from the record (closes #36) ConsoleHandler found the file and line it prints by walking a fixed number of stack frames up from itself, which only fit a record logged through a slog.Logger and delivered through MultiplexHandler. It now reads them from the record's PC, the call site slog stores, so a ConsoleHandler used with slog.New and a record passed to Handle directly print the right location too. A record whose PC is zero prints ???:0 as before. The README drops its warning about the wrong location and says how to set PC instead. Model: opus-5-5 --- README.md | 5 +- TODO.md | 4 ++ console_handler.go | 16 +++-- console_handler_internal_test.go | 102 +++++++++++++++++++++++++++++++ 4 files changed, 116 insertions(+), 11 deletions(-) create mode 100644 console_handler_internal_test.go diff --git a/README.md b/README.md index d9aa4b8..91d5631 100644 --- a/README.md +++ b/README.md @@ -126,8 +126,9 @@ error away. That is how `log/slog` works, and simplelog cannot change it. To find out whether a record was delivered, build a `slog.Record` and pass it to the handler yourself, for example `slog.Default().Handler().Handle(ctx, record)`, then check the error it -returns. A record passed to `Handle` directly gets the wrong file and -line in console output. +returns. Console output takes the file and line from the record's `PC`, +which you set with `runtime.Callers` as the `log/slog` documentation +shows. A record whose `PC` is zero prints `???:0` instead. ## Entrypoints diff --git a/TODO.md b/TODO.md index b16ca6a..5e15b59 100644 --- a/TODO.md +++ b/TODO.md @@ -24,6 +24,10 @@ files it depends on: .golangci.yml, REPO_POLICIES.md, .editorconfig, # Completed Steps +* 2026-10-06: the console handler takes the file and line it prints from + the record's `PC` instead of counting stack frames, so they name the + call site whether the record came through a `slog.Logger` or straight + to `Handle` * 2026-10-06: the tests run under the race detector, in Docker: `script/test` builds the `test` stage of the `Dockerfile`, which runs `go test -race`, and a new final stage makes a plain `docker build` diff --git a/console_handler.go b/console_handler.go index 9aa303c..5a4448b 100644 --- a/console_handler.go +++ b/console_handler.go @@ -12,10 +12,6 @@ import ( "github.com/fatih/color" ) -// callerSkipFrames is the number of stack frames between runtime.Caller -// and the slog call site that produced the record. -const callerSkipFrames = 4 - // ConsoleHandler writes human-readable, colored log lines to stdout. type ConsoleHandler struct { // out is where records are written. Nil means os.Stdout, looked up on @@ -53,11 +49,13 @@ func (c *ConsoleHandler) Handle( colorFunc = color.New(color.FgWhite).SprintfFunc() } - // Get the caller information - _, file, line, ok := runtime.Caller(callerSkipFrames) - if !ok { - file = "???" - line = 0 + // The file and line come from the record's PC, the call site; a record + // without one prints a placeholder. + file, line := "???", 0 + + if record.PC != 0 { + frame, _ := runtime.CallersFrames([]uintptr{record.PC}).Next() + file, line = frame.File, frame.Line } out := c.out diff --git a/console_handler_internal_test.go b/console_handler_internal_test.go new file mode 100644 index 0000000..efcb700 --- /dev/null +++ b/console_handler_internal_test.go @@ -0,0 +1,102 @@ +package simplelog + +import ( + "bytes" + "context" + "fmt" + "log/slog" + "runtime" + "strings" + "testing" + "time" +) + +// These tests sit inside the package so they can read the console line +// from a buffer instead of from stdout. + +// lineOf calls logCall and returns "file:line" for the line lineOf was +// called from. Each test writes its log call inside logCall on that same +// line, so the result is the location the console line must name. +func lineOf(logCall func()) string { + _, file, line, _ := runtime.Caller(1) + + logCall() + + return fmt.Sprintf("%s:%d", file, line) +} + +// wantLocation fails the test unless the console line names location as +// where the record "casting" was logged. +func wantLocation(t *testing.T, output, location string) { + t.Helper() + + if !strings.Contains(output, location+": casting") { + t.Fatalf("console line %q does not name %s", output, location) + } +} + +// The default handler as simplelog installs it when stdout is a terminal: +// a MultiplexHandler holding a ConsoleHandler. +func TestConsoleHandlerNamesCallSiteThroughDefaultHandler(t *testing.T) { + t.Parallel() + + var output bytes.Buffer + + logger := slog.New(&MultiplexHandler{handlers: []ExtendedHandler{ + &ConsoleHandler{out: &output}, + }}) + + want := lineOf(func() { logger.Info("casting") }) + + wantLocation(t, output.String(), want) +} + +func TestConsoleHandlerNamesCallSiteUsedWithSlogNew(t *testing.T) { + t.Parallel() + + var output bytes.Buffer + + logger := slog.New(&ConsoleHandler{out: &output}) + + want := lineOf(func() { logger.Info("casting") }) + + wantLocation(t, output.String(), want) +} + +// The record is built as the log/slog package documentation shows for a +// function that logs on its caller's behalf: runtime.Callers supplies the +// PC. +func TestConsoleHandlerNamesCallSiteOfRecordPassedToHandle(t *testing.T) { + t.Parallel() + + var ( + output bytes.Buffer + pcs [1]uintptr + ) + + want := lineOf(func() { runtime.Callers(1, pcs[:]) }) + + record := slog.NewRecord(time.Now(), slog.LevelInfo, "casting", pcs[0]) + + err := (&ConsoleHandler{out: &output}).Handle(context.Background(), record) + if err != nil { + t.Fatalf("Handle: %v", err) + } + + wantLocation(t, output.String(), want) +} + +func TestConsoleHandlerPrintsPlaceholderWithoutPC(t *testing.T) { + t.Parallel() + + var output bytes.Buffer + + record := slog.NewRecord(time.Now(), slog.LevelInfo, "casting", 0) + + err := (&ConsoleHandler{out: &output}).Handle(context.Background(), record) + if err != nil { + t.Fatalf("Handle: %v", err) + } + + wantLocation(t, output.String(), "???:0") +} -- 2.54.0 From db3373c71dba87032336ac8aa276e5b7d4134c7a Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Tue, 6 Oct 2026 15:10:23 +0200 Subject: [PATCH 7/9] Give the webhook handler a timeout (closes #38) The webhook handler's client had no timeout, and slog calls the handler inside the log call, so a server that accepted the connection and never answered stopped that log call for good, and every later one. A request still running after 5 seconds, reading the answer included, now fails with a timeout error. The handler also reads the answer to the end before closing it, so the connection is reused for the next record. Two new tests point the handler at a server that never answers and at one that sends its status and then stalls the answer, and check that Handle returns a timeout error within the timeout. The README states the timeout. Model: opus-5-5 --- README.md | 4 +- TODO.md | 3 + handler_errors_internal_test.go | 101 ++++++++++++++++++++++++++++++++ webhook_handler.go | 20 ++++++- 4 files changed, 126 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 91d5631..387ac67 100644 --- a/README.md +++ b/README.md @@ -116,7 +116,9 @@ the record: stdout, wrapped, so `errors.Is` still matches the original - `WebhookHandler` returns an error when the request fails, and also when the server answers with a status outside 2xx, a redirect included, - since it does not follow redirects + since it does not follow redirects. A request that has not finished + after 5 seconds fails with a timeout error, so a webhook server that + never answers holds up a log call for 5 seconds at most - `MultiplexHandler`, which simplelog installs as the default, passes the record to every handler it holds even after one of them fails, then returns all their errors joined with `errors.Join` (nil if none failed) diff --git a/TODO.md b/TODO.md index 5e15b59..f67cfb8 100644 --- a/TODO.md +++ b/TODO.md @@ -24,6 +24,9 @@ files it depends on: .golangci.yml, REPO_POLICIES.md, .editorconfig, # Completed Steps +* 2026-10-06: a webhook request now times out after 5 seconds, so a + server that never answers no longer stops every log call; the webhook + handler also reads each answer to the end so its connection is reused * 2026-10-06: the console handler takes the file and line it prints from the record's `PC` instead of counting stack frames, so they name the call site whether the record came through a `slog.Logger` or straight diff --git a/handler_errors_internal_test.go b/handler_errors_internal_test.go index 5c4af86..743aca8 100644 --- a/handler_errors_internal_test.go +++ b/handler_errors_internal_test.go @@ -5,6 +5,7 @@ import ( "context" "errors" "log/slog" + "net" "net/http" "net/http/httptest" "strings" @@ -160,3 +161,103 @@ func TestWebhookHandlerReturnsErrorOnRedirect(t *testing.T) { t.Fatalf("Handle returned %v, want an error for the redirect", err) } } + +// A server that accepts the request and never answers must not hold up +// the log call: Handle gives up after webhookTimeout and says why. +func TestWebhookHandlerTimesOutOnServerThatNeverAnswers(t *testing.T) { + t.Parallel() + + testEnded := make(chan struct{}) + + server := httptest.NewServer(http.HandlerFunc( + func(_ http.ResponseWriter, _ *http.Request) { + <-testEnded + }, + )) + + // server.Close waits for running requests, so the server's handler + // is released first. + defer func() { + close(testEnded) + server.Close() + }() + + handler, err := NewWebhookHandler(server.URL) + if err != nil { + t.Fatalf("NewWebhookHandler: %v", err) + } + + // Handle runs in a goroutine so that a lost timeout fails the test + // instead of hanging the test run. + handleErr := make(chan error, 1) + + go func() { + handleErr <- handler.Handle(context.Background(), errorTestRecord()) + }() + + select { + case err = <-handleErr: + case <-time.After(webhookTimeout + time.Second): + t.Fatal("Handle did not return within webhookTimeout") + } + + var netErr net.Error + if !errors.As(err, &netErr) || !netErr.Timeout() { + t.Fatalf("Handle returned %v, want a timeout error", err) + } +} + +// A server that sends a 2xx status and then never finishes the answer +// must not hold up the log call either: reading the answer counts toward +// webhookTimeout, and Handle returns the read's error. +func TestWebhookHandlerTimesOutOnServerThatStallsTheAnswer(t *testing.T) { + t.Parallel() + + testEnded := make(chan struct{}) + + server := httptest.NewServer(http.HandlerFunc( + func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + + // Flush sends the status now; the answer stays unfinished + // until the test ends. + err := http.NewResponseController(w).Flush() + if err != nil { + t.Errorf("flush the status: %v", err) + } + + <-testEnded + }, + )) + + // server.Close waits for running requests, so the server's handler + // is released first. + defer func() { + close(testEnded) + server.Close() + }() + + handler, err := NewWebhookHandler(server.URL) + if err != nil { + t.Fatalf("NewWebhookHandler: %v", err) + } + + // Handle runs in a goroutine so that a lost timeout fails the test + // instead of hanging the test run. + handleErr := make(chan error, 1) + + go func() { + handleErr <- handler.Handle(context.Background(), errorTestRecord()) + }() + + select { + case err = <-handleErr: + case <-time.After(webhookTimeout + time.Second): + t.Fatal("Handle did not return within webhookTimeout") + } + + var netErr net.Error + if !errors.As(err, &netErr) || !netErr.Timeout() { + t.Fatalf("Handle returned %v, want a timeout error", err) + } +} diff --git a/webhook_handler.go b/webhook_handler.go index a7cf9b5..a6e47c4 100644 --- a/webhook_handler.go +++ b/webhook_handler.go @@ -6,15 +6,22 @@ import ( "encoding/json" "errors" "fmt" + "io" "log/slog" "net/http" "net/url" + "time" ) // errWebhookStatus is returned when the webhook answers with a status // outside 2xx. var errWebhookStatus = errors.New("webhook did not accept the record") +// webhookTimeout bounds each webhook request, reading the answer +// included. Handle runs inside the log call, so a server that never +// answers would otherwise hold that call up for good. +const webhookTimeout = 5 * time.Second + // WebhookHandler POSTs each log record as JSON to a configured webhook // URL. type WebhookHandler struct { @@ -34,6 +41,7 @@ func NewWebhookHandler(webhookURL string) (*WebhookHandler, error) { return &WebhookHandler{ webhookURL: webhookURL, client: &http.Client{ + Timeout: webhookTimeout, // Following a redirect can resend the request as a GET // without the record, so Handle gets the redirect answer // itself and returns it as an error. @@ -80,7 +88,8 @@ func (w *WebhookHandler) WithGroup(name string) slog.Handler { // Handle marshals the record, with its attributes, to one JSON object and // POSTs it to the webhook URL. It returns an error when the request fails -// or the server answers with a status outside 2xx. +// or runs past webhookTimeout, or when the server answers with a status +// outside 2xx. func (w *WebhookHandler) Handle(ctx context.Context, record slog.Record) error { jsonData, err := json.Marshal(recordToMap(record, w.attrs)) if err != nil { @@ -106,6 +115,15 @@ func (w *WebhookHandler) Handle(ctx context.Context, record slog.Record) error { defer func() { _ = response.Body.Close() }() + // The answer is read to the end so the client can reuse the + // connection for the next record. The read counts toward + // webhookTimeout, so a server that sends its status and then stalls + // fails here. + _, err = io.Copy(io.Discard, response.Body) + if err != nil { + return fmt.Errorf("error reading webhook answer: %w", err) + } + if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices { return fmt.Errorf("%w: %s", errWebhookStatus, response.Status) -- 2.54.0 From 17796569b8f842d7e7f879083483353a9f1852d1 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Tue, 6 Oct 2026 15:55:10 +0200 Subject: [PATCH 8/9] Re-vendor the standard files from sneak/prompts dd4027b (closes #33) REPO_POLICIES.md, .golangci.yml, the CI workflow and the scripts the policy keeps identical across repositories are copies of the files at that commit. .gitignore, .editorconfig and the new .dockerignore are the canonical files followed by this repository's own entries. The lint stage moves to the golangci-lint image the policy pins, in the same commit as .golangci.yml. The format check leaves the lint stage and runs on the host from script/check, which script/cibuild now runs after script/bootstrap. The last Dockerfile stage runs script/bootstrap, which installs goimports with go install at a pinned commit; script/fmt runs that goimports. Model: opus-5-5 --- .dockerignore | 81 +++++++ .editorconfig | 15 ++ .gitea/workflows/check.yml | 17 +- .gitignore | 57 ++++- .golangci.yml | 69 +++++- Dockerfile | 18 +- README.md | 19 +- REPO_POLICIES.md | 443 ++++++++++++++++++++++++++++++------- TODO.md | 16 +- script/bootstrap | 35 +++ script/check | 4 +- script/cibuild | 21 +- script/docker | 5 +- script/fmt | 7 +- 14 files changed, 679 insertions(+), 128 deletions(-) create mode 100644 .dockerignore create mode 100644 .editorconfig diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..fd0ef7a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,81 @@ +# .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 host-built artifacts: the example program's binary, +# test binaries and coverage output. +/cmd/example/example +/*.test +/*.out + +# aider's files, among them a config file that can hold an API key. +**/.aider* diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..92ec261 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +root = true + +[*] +indent_style = space +indent_size = 4 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[Makefile] +indent_style = tab + +[*.go] +indent_style = tab diff --git a/.gitea/workflows/check.yml b/.gitea/workflows/check.yml index 19a2731..ee73864 100644 --- a/.gitea/workflows/check.yml +++ b/.gitea/workflows/check.yml @@ -1,12 +1,9 @@ name: check - -on: - push: - pull_request: - +on: [push] jobs: - check: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - run: script/cibuild + check: + runs-on: ubuntu-latest + steps: + # actions/checkout v4.2.2, 2026-02-22 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + - run: script/cibuild diff --git a/.gitignore b/.gitignore index 176a35f..0e3ea25 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,57 @@ -.aider* +# OS +.DS_Store +Thumbs.db + +# Editors +*.swp +*.swo +*~ +*.bak +.idea/ +.vscode/ +*.sublime-* + +# 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/ + +# Secrets. Unanchored like every entry above, so each matches at every +# depth. Matching is case-sensitive on Linux, so names use character +# ranges rather than a lowercase form that misses `Server.Key`. + +# Environment files. `*.env` covers bare `.env` and the `prod.env` +# convention. Only the templates `example.env` and `sample.env` are +# re-included below. A repository that commits any other template adds +# its own negation after these lines, 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. Go: logs, coverage output, test +# binaries, and the example program's binary. +*.log +*.out +*.test cmd/example/example + +# aider's files, among them a config file that can hold an API key. +.aider* diff --git a/.golangci.yml b/.golangci.yml index 26b1610..1b73eb9 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -10,14 +10,21 @@ run: linters: default: all + enable: + # Successor to the deprecated gomodguard. Named explicitly, rather than + # left to `default: all`, because it carries the module policy below. + - gomodguard_v2 disable: # Genuinely incompatible with project patterns - exhaustruct # Requires all struct fields - - depguard # Dependency allow/block lists + - exhaustruct_v5 # Requires all struct fields (successor to exhaustruct) - godot # Requires comments to end with periods - - wsl # Deprecated, replaced by wsl_v5 - 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. + - wsl # Deprecated, replaced by wsl_v5 + - gomodguard # Deprecated, replaced by gomodguard_v2 settings: lll: line-length: 88 @@ -28,6 +35,64 @@ linters: max-complexity: 15 dupl: threshold: 100 + depguard: + # Test-support code must not be compiled into the shipped binary. A + # 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. + # + # 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 + # import path against a list of prefixes, so it cannot be told "any path + # whose last segment ends in test"; a repository's own test-support + # packages have to be named here one at a time, by full import path, + # under a module path that differs from repository to repository. Add + # them; change nothing else. + rules: + test-support: + list-mode: lax + files: + - "$all" + - "!$test" + - "!**/*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. + 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. + - module: github.com/go-redis/redis + recommendations: + - github.com/redis/go-redis/v9 + reason: "Pre-fork module; use the maintained go-redis v9." + - module: github.com/go-redis/redis/v7 + recommendations: + - github.com/redis/go-redis/v9 + reason: "Pre-fork module; use the maintained go-redis v9." + - module: github.com/go-redis/redis/v8 + 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 + reason: "No unified diff output; use go-udiff." + - module: github.com/hexops/gotextdiff + recommendations: + - github.com/aymanbagabas/go-udiff + reason: "Unmaintained fork; use go-udiff." issues: max-issues-per-linter: 0 diff --git a/Dockerfile b/Dockerfile index 61f5e60..e38ad31 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,11 +1,11 @@ -# Lint stage: format check + golangci-lint -# golangci/golangci-lint:v2.12.2 (Debian-based), 2026-08-07 -FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS lint +# Lint stage. The format check is not here: it runs on the host, in +# script/fmt-check. +# golangci/golangci-lint:v2.14.0 (Debian-based), 2026-09-24 +FROM golangci/golangci-lint@sha256:ad862ba6b3798cbe0fd9fd7408d498fd74fbd2623a92406b2fd3898faf0bf98f AS lint WORKDIR /src COPY go.mod go.sum ./ RUN go mod download COPY . . -RUN make fmt-check # Called directly: make lint is itself a docker build of this stage. RUN golangci-lint run --config .golangci.yml ./... @@ -21,8 +21,16 @@ RUN go test -timeout 90s -race -cover ./... || \ { echo "--- Rerunning with -v for details ---"; \ go test -timeout 90s -race -v ./...; exit 1; } -# Final stage: the copies make a plain docker build run both stages above. +# Final stage: a development environment, set up by script/bootstrap. +# The copies make a plain docker build run both stages above. No stage +# compiles a binary, since simplelog is a library, so none declares the +# VERSION build argument that script/docker and script/cibuild pass. # golang 1.22.12 (2025-02-04) FROM golang@sha256:1cf6c45ba39db9fd6db16922041d074a63c935556a05c5ccb62d181034df7f02 COPY --from=lint /src/go.sum /dev/null COPY --from=test /src/go.sum /dev/null +WORKDIR /src +COPY script/ script/ +COPY go.mod go.sum ./ +RUN script/bootstrap +COPY . . diff --git a/README.md b/README.md index 387ac67..a64aa34 100644 --- a/README.md +++ b/README.md @@ -141,9 +141,10 @@ development workflow, and the Makefile targets are thin shims that call them. The scripts are POSIX sh (not bash) so they run in minimal containers such as alpine. We provide: -- `script/bootstrap` — install all dependencies (go if missing, then - `go mod download`); golangci-lint is not installed, since it runs only in - Docker +- `script/bootstrap` — install all dependencies (go if missing, goimports with + `go install` at a pinned commit unless the installed one already reports the + pinned version, then `go mod download`); golangci-lint is not installed, + since it runs only in Docker - `script/setup` — set up the repo for development after a fresh clone: runs `script/bootstrap`, then `script/install-precommit` - `script/projectname` — output the project name (our own extension); used by @@ -152,9 +153,10 @@ alpine. We provide: building only the `test` stage of the `Dockerfile`, without the build cache, so every run tests; the image is tagged `simplelog-test` - `script/lint` — run golangci-lint in Docker by building only the `lint` - stage of the `Dockerfile` (which also runs the format check), without the - build cache, so every run lints; the image is tagged `simplelog-lint` -- `script/fmt` — format all files with goimports (writes) + stage of the `Dockerfile`, without the build cache, so every run lints; the + image is tagged `simplelog-lint` +- `script/fmt` — format all files (writes) with the goimports that + `script/bootstrap` installs - `script/fmt-check` — check formatting (read-only); fails if `gofmt -l` reports files - `script/check` — run all checks: `test`, `lint`, `fmt-check` (our own @@ -163,9 +165,8 @@ alpine. We provide: via `script/projectname` (byte-identical across repos); it also passes the output of `git describe` as the `VERSION` build argument, which is ignored because this `Dockerfile` does not declare it -- `script/cibuild` — cd to the repo root and build the Docker image without the - build cache, tagged via `script/projectname` (what CI runs; the image build - runs the checks) +- `script/cibuild` — what CI runs: `script/bootstrap`, then `script/check`, + then the same image build as `script/docker` (byte-identical across repos) - `script/precommit` — run by the git pre-commit hook (our own extension); runs a `go mod tidy` guard, then `script/check` - `script/install-precommit` — installs the git pre-commit hook (our own diff --git a/REPO_POLICIES.md b/REPO_POLICIES.md index bc2f161..20382d1 100644 --- a/REPO_POLICIES.md +++ b/REPO_POLICIES.md @@ -1,6 +1,6 @@ --- title: Repository Policies -last_modified: 2026-07-06 +last_modified: 2026-10-04 --- This document covers repository structure, tooling, and workflow standards. Code @@ -60,17 +60,28 @@ style conventions are in separate documents: 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 and runs `docker build .`; the Gitea workflow calls it. 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 + 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). @@ -89,87 +100,198 @@ style conventions are in separate documents: contributor should be able to understand the entire development workflow by reading the Makefile. -- Every repo should have a `Dockerfile`. All Dockerfiles must run `make check` - as a build step so the build fails if the branch is not green. For non-server - repos, the Dockerfile should bring up a development environment and run - `make check`. For server repos, `make check` should run as an early build - stage before the final image is assembled. 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 so the bootstrap - layer stays cached until dependencies change. +- 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. + The gate phases and the build stage start from their pinned base images and + install what those images lack either inline, as the canonical Go `Dockerfile` + below does for `git`, or by running `script/bootstrap`, as the `prompts` + repo's own `Dockerfile` does for its yarn packages. The development + environment stage installs development prerequisites by running + `script/bootstrap` rather than duplicating its installs inline. A stage that + runs `script/bootstrap` COPYs `script/` and the dependency manifests + (`package.json` + `yarn.lock`, `go.mod` + `go.sum`, etc.) before running it. -- **Dockerfiles must use a separate lint stage for fail-fast feedback.** Go - repos use a multistage build where linting runs in an independent stage based - on the `golangci/golangci-lint` image (pinned by hash). This stage runs - `make fmt-check` and `make lint` before the full build begins. The build stage - then declares an explicit dependency on the lint stage via - `COPY --from=lint /src/go.sum /dev/null`, which forces BuildKit to complete - linting before proceeding to compilation and tests. This ensures lint failures - surface in seconds rather than minutes, without blocking on dependency - download or compilation in the build stage. +- **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: - The standard pattern for a Go repo Dockerfile is: + ```sh + docker build --no-cache --target lint -t "$(script/projectname)-lint" . + docker build --no-cache --target test -t "$(script/projectname)-test" . + ``` + + **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. + + **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. + + 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. + + **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. + +- **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. + When a check is added or changed, prove it works by planting a defect it must + catch and watching the run fail on it, then revert the defect. A green run + alone shows neither that the check ran nor that it covers what it should. + +- **The gate phases are separate stages, and the build stage depends on both.** + The lint phase is based on the `golangci/golangci-lint` image (pinned by + hash), so lint failures surface in seconds rather than after a full compile, + and the test phase is based on the Debian Go image. The canonical Go repo + `Dockerfile`: ```dockerfile - # Lint stage — fast feedback on formatting and lint issues + # 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 COPY . . - RUN make fmt-check - RUN make lint + RUN golangci-lint run --config .golangci.yml ./... - # Build stage - # golang:1.x-alpine, YYYY-MM-DD - FROM golang@sha256:... AS builder + # Test phase. -race needs cgo and so a C compiler, which the Debian Go + # image ships and the alpine one does not. + # golang:1.x, YYYY-MM-DD + FROM golang@sha256:... AS test WORKDIR /src - - # Force BuildKit to run the lint stage before proceeding - COPY --from=lint /src/go.sum /dev/null - COPY go.mod go.sum ./ RUN go mod download COPY . . - RUN make test + RUN go test -timeout 90s -race -cover ./... || \ + { echo "--- Rerunning with -v for details ---"; \ + go test -timeout 90s -race -v ./...; exit 1; } - ARG VERSION=dev - RUN CGO_ENABLED=0 go build -trimpath \ - -ldflags="-s -w -X main.Version=${VERSION}" \ - -o /app ./cmd/app/ + # Build stage. Nothing is wanted from either phase above; the copies + # are what make BuildKit build them first, so this stage cannot run + # unless lint and test passed. + # golang:1.x-alpine, YYYY-MM-DD + FROM golang@sha256:... AS builder + COPY --from=lint /src/go.sum /dev/null + COPY --from=test /src/go.sum /dev/null + RUN apk add --no-cache git + # A tar-stream context keeps the sender's file owners, which git refuses. + RUN git config --system --add safe.directory /src + WORKDIR /src + COPY go.mod go.sum ./ + RUN go mod download + COPY . . - # Runtime stage + # 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 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:... COPY --from=builder /app /usr/local/bin/app ENTRYPOINT ["app"] ``` Key points: - - The lint stage uses the `golangci/golangci-lint` image directly (it - includes both Go and the linter), so there is no need to install the - linter separately. - - `COPY --from=lint /src/go.sum /dev/null` is a no-op file copy that creates - a stage dependency. BuildKit runs stages in parallel by default; without - this line, the build stage would not wait for lint to finish and a lint - failure might not fail the overall build. + - The lint phase uses the `golangci/golangci-lint` image directly (it has + both Go and the linter), so nothing needs installing. + - `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 stage must + (e.g. a web frontend compiled in a separate stage), the lint phase must create placeholder files so the embed directives resolve. Example: `RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css`. - The lint stage should not depend on the actual build output — it exists to - fail fast. - - If the project requires CGO or system libraries for linting (e.g. - `vips-dev`), install them in the lint stage with `apk add`. - - The build stage runs `make test` after compilation setup. Tests run in the - build stage, not the lint stage, because they may require compiled - artifacts or heavier dependencies. + - If the project requires CGO or system libraries for linting, install them + in the lint phase. The `golangci/golangci-lint` image is Debian-based and + has no `apk`, so install with `apt-get` under the Debian package name + (`libvips-dev`, where alpine says `vips-dev`), and delete the package + lists in the same `RUN`, so the layer does not keep them: + + ```dockerfile + RUN apt-get update \ + && apt-get install -y --no-install-recommends libvips-dev \ + && rm -rf /var/lib/apt/lists/* + ``` + + - `.dockerignore` lets `.git` into the build context. It keeps out every git + `config` at any depth (`**/.git/config`, `**/.git/modules/**/config`): the + repository's own, each submodule's under `.git/modules/`, and that of a + submodule keeping its own `.git` directory. `git describe` does not need + them, and each can hold a credential: a password in a remote URL, or the + token the CI checkout step stores there. A submodule whose name has a + `config` segment (`config`, `deploy/config`, `config/lib`) loses its whole + git directory to `**/.git/modules/**/config`, and Go's version stamping + then fails the build: give it a name without that segment + (`git submodule add --name`). The stage that compiles has `git` (the + Debian Go image has it; an alpine one needs `apk add --no-cache git`) and + takes the version from the `VERSION` build argument when one is given, + otherwise from `git describe --tags --always`. That gives the tag on a + tagged commit; on a later commit, the tag, the number of commits since it + and the short commit (`v1.2.3-4-gabc1234`); and the short commit when no + tag is reachable. The stage that compiles also marks its working directory + safe for git (`git config --system --add safe.directory /src`): a context + sent as a tar stream keeps the sender's file owners, and git refuses a + checkout owned by another user, so the version would come out empty. + `ARG VERSION` has no default, and the build fails if the context carries + `.git` and the version still comes out empty, `dev` or `unknown`. A plain + `docker build .` with no build arguments must succeed; a Dockerfile that + refuses an empty build argument drops that refusal and keeps the argument. - Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that - runs `script/cibuild` (which runs `docker build .`) on push. Since the - Dockerfile already runs `make check`, a successful build implies all checks - pass. + 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 + 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. 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 @@ -189,14 +311,21 @@ style conventions are in separate documents: module under test to verify it compiles/parses. There is no excuse for `make test` to be a no-op. -- `make test` must complete in under 20 seconds. Add a 30-second timeout in the - Makefile. +- `make test` must complete in under 60 seconds. That is the hard cap, and a + suite that exceeds it fails. Under 20 seconds is the target. A suite between + 20 and 60 seconds is still green, but the overage must be filed as an + 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. -- **`make test` 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 general shell pattern: +- **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: ```makefile test: @@ -209,11 +338,26 @@ style conventions are in separate documents: ```makefile test: - @go test -timeout 30s -race -cover ./... || \ + @go test -count=1 -timeout 90s -race -cover ./... || \ { echo "--- Rerunning with -v for details ---"; \ - go test -timeout 30s -race -v ./...; exit 1; } + 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 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`), and when the same tests + run again on unchanged code it prints that result, marked `(cached)`, + without running them. That matters on a developer's machine, where this + target runs and the directory lasts from one run to the next. The `test` + phase of the `Dockerfile` needs no `-count=1`: its base image holds no + result for this repo's tests and nothing before its `go test` step runs a + test, so there is nothing to replay. `--no-cache` (above) is what makes that + step run on an unchanged tree. + Python example: ```makefile @@ -239,10 +383,84 @@ style conventions are in separate documents: must be in `.gitignore`. No exceptions. - `.gitignore` should be comprehensive from the start: OS files (`.DS_Store`), - editor files (`.swp`, `*~`), 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. + 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. + +- **`.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 + 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: `.claude`, and the repo's own host-built + binary, written `/myapp` and never `**/myapp`, which would also match + `cmd/myapp/` and delete the package directory from the context. Matching is + case-sensitive, and an ALL-CAPS twin per pattern still misses `Server.Key`, so + 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 + 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. + +- **A plain `docker build .` of a clone stamps the version that + `git describe --tags --always` gives**, derived from the `.git` in the build + context as the canonical `Dockerfile` above shows. Without its failure check, + a missing `git` or an unreadable checkout would leave `-X main.Version=` empty + and the build would still exit 0. `script/docker` and `script/cibuild` pass + the version they compute on the host; it takes precedence. They do this + byte-identically across repos: + + ```sh + # Own line: a failing command substitution inside an argument does not + # trip `set -e`, so the inline form degrades to an empty constant. + version="$(git describe --tags --always --dirty 2>/dev/null || true)" + [ -n "$version" ] || version="unknown" + docker build --no-cache \ + --build-arg VERSION="$version" \ + -t "$(script/projectname)" . + ``` + + `--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` 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. + +- **Verify `.dockerignore` by enumerating the image, not by reading the + 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 @@ -258,9 +476,56 @@ style conventions are in separate documents: - Make all changes on a feature branch. You can do whatever you want on a feature branch. -- `.golangci.yml` is standardized and must _NEVER_ be modified by an agent, only - manually by the user. Fetch from - `https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml`. +- `.golangci.yml` is standardized. The vendored copy in a consuming repo must + _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 + 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 + 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.14.0 (released 2026-09-24), pinned as the digest of the lint phase's base + image + (`golangci/golangci-lint@sha256:ad862ba6b3798cbe0fd9fd7408d498fd74fbd2623a92406b2fd3898faf0bf98f`, + which reports `2.14.0 built with go1.27.0 from 114493f9`). A module's `go` + directive must not name a newer Go minor version than the one golangci-lint + was built with, or golangci-lint refuses to lint it: this release lints + `go 1.27.1` but not `go 1.28`. That digest is the only pin, since no repo + installs golangci-lint on the host. A repo sets the lint phase digest to the + one named here and re-vendors `.golangci.yml` in the same commit, whichever of + the two prompted the change: the canonical copy can name linters that an older + golangci-lint rejects, and a newer golangci-lint can add linters that + `default: all` switches on until the canonical copy disables them. + +- **`script/bootstrap` installs a pinned tool by comparing versions, never by + testing presence.** An `if ! command -v ; then install; fi` guard tests + `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 + 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 + function defined and never invoked has the same exit status and the same + empty output as one that worked. + + 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). @@ -374,12 +639,14 @@ style conventions are in separate documents: settings. - Avoid putting files in the repo root unless necessary. Root should contain - only project-level config files (`README.md`, `Makefile`, `Dockerfile`, - `LICENSE`, `.gitignore`, `.editorconfig`, `REPO_POLICIES.md`, and - language-specific config). Everything else goes in a subdirectory. Canonical - subdirectory names: + only project-level config files (`README.md`, `AGENTS.md`, `Makefile`, + `Dockerfile`, `LICENSE`, `.gitignore`, `.editorconfig`, `REPO_POLICIES.md`, + and language-specific config). Everything else goes in a subdirectory. + Canonical subdirectory names: - `bin/` — executable scripts and tools - - `cmd/` — Go command entrypoints + - `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 + `cmd/` - `configs/` — configuration templates and examples - `deploy/` — deployment manifests (k8s, compose, terraform) - `docs/` — documentation and markdown (README.md stays in root) @@ -406,3 +673,7 @@ style conventions are in separate documents: - Go: `go.mod`, `go.sum`, `.golangci.yml` - JS: `package.json`, `yarn.lock`, `.prettierrc`, `.prettierignore` - Python: `pyproject.toml` + +- Guidance for coding agents lives in one `AGENTS.md` at the repository root. It + is never committed under a file or directory named after one agent tool, such + as `CLAUDE.md` or `.claude/`, and never split into separate memory files. diff --git a/TODO.md b/TODO.md index f67cfb8..bbd37ae 100644 --- a/TODO.md +++ b/TODO.md @@ -17,13 +17,19 @@ maintenance; the library is referenced by the Go styleguide. # Next Step -Bring the Makefile up to policy in one commit: add fmt-check, check, and -hooks targets (test/lint/fmt/docker exist) and add the missing policy -files it depends on: .golangci.yml, REPO_POLICIES.md, .editorconfig, -.dockerignore, and .gitea/workflows/check.yml running make check. +Restructure README.md into the standard sections: Description, Getting +Started, Rationale, Design, TODO, License, Author # Completed Steps +* 2026-10-06: re-vendored the standard files from `sneak/prompts` at + commit `dd4027b`: `REPO_POLICIES.md`, the CI workflow, `.gitignore`, + and `.golangci.yml` together with the lint stage's new image, plus + the `.editorconfig` and `.dockerignore` this repo lacked, which + finishes the Makefile and policy-files step. `script/cibuild` now + bootstraps and runs `script/check` before building the image, the + format check runs only on the host, the last `Dockerfile` stage runs + `script/bootstrap`, which installs goimports at a pinned commit * 2026-10-06: a webhook request now times out after 5 seconds, so a server that never answers no longer stops every log call; the webhook handler also reads each answer to the end so its connection is reused @@ -66,8 +72,6 @@ files it depends on: .golangci.yml, REPO_POLICIES.md, .editorconfig, # Future Steps -* Restructure README.md into the standard sections: Description, - Getting Started, Rationale, Design, TODO, License, Author * Delete the old plain TODO file once this TODO.md lands * Add .aider.* to .gitignore and remove the stray aider artifacts from the working tree diff --git a/script/bootstrap b/script/bootstrap index d0f3814..02277dc 100755 --- a/script/bootstrap +++ b/script/bootstrap @@ -7,6 +7,11 @@ set -eu ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" +# goimports from golang.org/x/tools v0.30.0, 2025-02-10: the last release +# that Go 1.22, the Go of go.mod and the Dockerfile, can build. +GOIMPORTS_COMMIT="09747cdf594a7924dcecb506312be3bd6e437962" +GOIMPORTS_VERSION="v0.30.0" + PKGMGR="" SUDO="" @@ -47,6 +52,35 @@ missing() { ! command -v "$1" >/dev/null 2>&1 } +# Print the golang.org/x/tools version that the goimports on PATH was +# built from, or nothing. goimports has no --version flag; `go version -m` +# prints the module versions Go records in every binary it builds. +goimports_version() { + bin="$(command -v goimports)" || return 0 + go version -m "$bin" 2>/dev/null | + awk '$1 == "mod" && $2 == "golang.org/x/tools" { print $3 }' +} + +# Install goimports at the pinned commit unless the one on PATH already +# reports the pinned version. go install writes to GOBIN, or to the bin +# directory of GOPATH when GOBIN is unset, which need not be on the +# caller's PATH; script/fmt puts that directory first on PATH the same +# way, so the check here sees the goimports that script/fmt runs. +ensure_goimports() { + gobin="$(go env GOBIN)" + PATH="${gobin:-$(go env GOPATH)/bin}:$PATH" + if [ "$(goimports_version)" != "$GOIMPORTS_VERSION" ]; then + go install "golang.org/x/tools/cmd/goimports@$GOIMPORTS_COMMIT" + hash -r + if [ "$(goimports_version)" != "$GOIMPORTS_VERSION" ]; then + echo "bootstrap: goimports on PATH is not $GOIMPORTS_VERSION" \ + "after installing it: $(command -v goimports || echo none)" >&2 + exit 1 + fi + fi + echo "goimports $GOIMPORTS_VERSION at $(command -v goimports)" +} + main() { cd "$ROOT" @@ -55,6 +89,7 @@ main() { if missing go; then pkg_install go golang go go; fi # golangci-lint is not installed: it runs only in docker (script/lint). + ensure_goimports go mod download diff --git a/script/check b/script/check index 3e1778c..92875f7 100755 --- a/script/check +++ b/script/check @@ -1,6 +1,8 @@ #!/bin/sh # script/check: run all checks (test, lint, fmt-check). Our own -# extension to scripts-to-rule-them-all. Must not modify any files. +# 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)" diff --git a/script/cibuild b/script/cibuild index 8b13f21..d8d3200 100755 --- a/script/cibuild +++ b/script/cibuild @@ -1,7 +1,10 @@ #!/bin/sh -# script/cibuild: run the CI build. The Dockerfile runs the format check, -# the linter and the tests, so a successful build means they all pass. -# --no-cache because a cached check step is a check that did not run. +# script/cibuild: run the CI build. It bootstraps first: a CI runner +# checks out and runs this and nothing else, and script/fmt-check runs +# the formatter on the host, which a pristine checkout cannot do. +# --no-cache for the same reason as script/docker: the gate phases the +# final stage depends on are RUN steps, and a cached one is a check that +# did not run. set -eu SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" @@ -9,7 +12,17 @@ ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)" main() { cd "$ROOT" - docker build --no-cache -t "$("$SCRIPT_DIR/projectname")" . + "$SCRIPT_DIR/bootstrap" + "$SCRIPT_DIR/check" + # Own line: a failing command substitution inside an argument does + # not trip `set -e`, so the inline form degrades silently to an + # empty constant. The VERSION build argument takes precedence over + # the version a build stage derives from the .git in the context. + version="$(git describe --tags --always --dirty 2>/dev/null || true)" + [ -n "$version" ] || version="unknown" + docker build --no-cache \ + --build-arg VERSION="$version" \ + -t "$("$SCRIPT_DIR/projectname")" . } main "$@" diff --git a/script/docker b/script/docker index c4688e8..07b626c 100755 --- a/script/docker +++ b/script/docker @@ -12,9 +12,8 @@ main() { cd "$ROOT" # Own line: a failing command substitution inside an argument does # not trip `set -e`, so the inline form degrades silently to an - # empty constant. VERSION is computed here because .dockerignore - # excludes .git, so `git describe` in a build stage yields an empty - # version without failing. + # empty constant. The VERSION build argument takes precedence over + # the version a build stage derives from the .git in the context. version="$(git describe --tags --always --dirty 2>/dev/null || true)" [ -n "$version" ] || version="unknown" docker build --no-cache \ diff --git a/script/fmt b/script/fmt index 64817df..0685452 100755 --- a/script/fmt +++ b/script/fmt @@ -1,11 +1,16 @@ #!/bin/sh -# script/fmt: format all files (writes). +# script/fmt: format all files (writes) with the goimports that +# script/bootstrap installs. It puts Go's bin directory first on PATH, as +# script/bootstrap does, so it runs that goimports even when the directory +# is not on the caller's PATH. set -eu ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" main() { cd "$ROOT" + gobin="$(go env GOBIN)" + PATH="${gobin:-$(go env GOPATH)/bin}:$PATH" goimports -l -w . } -- 2.54.0 From cb74aa593dad6889c536fd1e39f7e670c7a96ccb Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Tue, 6 Oct 2026 16:22:56 +0200 Subject: [PATCH 9/9] Format Markdown with prettier in make fmt (closes #43) script/fmt and script/fmt-check now run prettier on every Markdown file after their Go steps, as the standard scripts from sneak/prompts at dd4027b do. script/bootstrap gains the standard node, nvm and yarn install next to its goimports install, and the last Dockerfile stage copies package.json and yarn.lock before running it. .prettierrc, .prettierignore and yarn.lock are the standard copies. package.json is the standard one without its licence field and marked private, because this repo's licence is the WTFPL. .dockerignore keeps the log of a failed yarn install out of the build context. README.md and TODO.md are reformatted once with it; REPO_POLICIES.md already matched. Model: opus-5-5 --- .dockerignore | 4 +- .prettierignore | 2 + .prettierrc | 4 ++ Dockerfile | 2 +- README.md | 169 +++++++++++++++++++++++------------------------ TODO.md | 143 +++++++++++++++++++-------------------- package.json | 6 ++ script/bootstrap | 79 +++++++++++++++++++++- script/fmt | 28 ++++++-- script/fmt-check | 23 ++++++- yarn.lock | 8 +++ 11 files changed, 303 insertions(+), 165 deletions(-) create mode 100644 .prettierignore create mode 100644 .prettierrc create mode 100644 package.json create mode 100644 yarn.lock diff --git a/.dockerignore b/.dockerignore index fd0ef7a..aae93d2 100644 --- a/.dockerignore +++ b/.dockerignore @@ -72,10 +72,12 @@ **/*.sublime-* # This repository's host-built artifacts: the example program's binary, -# test binaries and coverage output. +# test binaries, coverage output, and the log yarn writes when an install +# fails. /cmd/example/example /*.test /*.out +/yarn-error.log # aider's files, among them a config file that can hold an API key. **/.aider* diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..23d67fc --- /dev/null +++ b/.prettierignore @@ -0,0 +1,2 @@ +node_modules/ +yarn.lock diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..8af31cd --- /dev/null +++ b/.prettierrc @@ -0,0 +1,4 @@ +{ + "tabWidth": 4, + "proseWrap": "always" +} diff --git a/Dockerfile b/Dockerfile index e38ad31..62eb613 100644 --- a/Dockerfile +++ b/Dockerfile @@ -31,6 +31,6 @@ COPY --from=lint /src/go.sum /dev/null COPY --from=test /src/go.sum /dev/null WORKDIR /src COPY script/ script/ -COPY go.mod go.sum ./ +COPY go.mod go.sum package.json yarn.lock ./ RUN script/bootstrap COPY . . diff --git a/README.md b/README.md index a64aa34..5c402ae 100644 --- a/README.md +++ b/README.md @@ -3,35 +3,33 @@ ## Summary simplelog is an opinionated logging package designed to facilitate easy and -structured logging in Go applications with an absolute minimum of -boilerplate. +structured logging in Go applications with an absolute minimum of boilerplate. -The idea is that you can add a single import line which replaces the -stdlib `log/slog` default handler, and solve the 90% case for logging. +The idea is that you can add a single import line which replaces the stdlib +`log/slog` default handler, and solve the 90% case for logging. ## Current Status -Released v1.0.0 2024-06-14. Works as intended. No known bugs. +Released v1.0.0 2024-06-14. Works as intended. No known bugs. ## Features -- if output is a tty, outputs pretty color logs -- if output is not a tty, outputs json -- supports delivering each log message via a webhook -- emits every `slog` attribute: those passed to a log call, those - accumulated with `WithAttrs`, and those qualified by `WithGroup`. - `slog.Group` values nest, and `slog.LogValuer` values are resolved. In - json output attributes are object fields (groups become nested objects); - in console output they are appended as `key=value` pairs, with grouped - keys written as `group.key=value`. See - [Attribute output](#attribute-output) for the details worth knowing -- reports a record that could not be delivered as an error from the - handler's `Handle` method. See - [When delivery fails](#when-delivery-fails) for how to receive it +- if output is a tty, outputs pretty color logs +- if output is not a tty, outputs json +- supports delivering each log message via a webhook +- emits every `slog` attribute: those passed to a log call, those accumulated + with `WithAttrs`, and those qualified by `WithGroup`. `slog.Group` values + nest, and `slog.LogValuer` values are resolved. In json output attributes are + object fields (groups become nested objects); in console output they are + appended as `key=value` pairs, with grouped keys written as `group.key=value`. + See [Attribute output](#attribute-output) for the details worth knowing +- reports a record that could not be delivered as an error from the handler's + `Handle` method. See [When delivery fails](#when-delivery-fails) for how to + receive it ## Planned Features -- supports delivering logs via tcp RELP (e.g. to remote rsyslog using imrelp) +- supports delivering logs via tcp RELP (e.g. to remote rsyslog using imrelp) ## Installation @@ -49,9 +47,9 @@ go get sneak.berlin/go/simplelog ## Usage -Below is an example of how to use SimpleLog in a Go application. This -example is provided in the form of a `main.go` file, which demonstrates -logging at various levels using structured logging syntax. +Below is an example of how to use SimpleLog in a Go application. This example is +provided in the form of a `main.go` file, which demonstrates logging at various +levels using structured logging syntax. ```go package main @@ -73,63 +71,58 @@ func main() { ## Attribute output Attributes reach every handler: the ones passed to the log call, the ones -accumulated with `WithAttrs`, and the ones qualified by the groups open at -the time they were attached. `slog.Group` values nest, and -`slog.LogValuer` values are resolved to the value they stand for. A few -behaviours are worth knowing before you rely on them. +accumulated with `WithAttrs`, and the ones qualified by the groups open at the +time they were attached. `slog.Group` values nest, and `slog.LogValuer` values +are resolved to the value they stand for. A few behaviours are worth knowing +before you rely on them. -**Your values are never modified.** Whatever you log is read and rendered, -never written to. A map or a slice you pass to `slog.Any` comes back from -the logger exactly as you handed it over, even when a group later uses the -same key. +**Your values are never modified.** Whatever you log is read and rendered, never +written to. A map or a slice you pass to `slog.Any` comes back from the logger +exactly as you handed it over, even when a group later uses the same key. -**Durations are nanoseconds in json, and readable on the console.** The -json and webhook payloads emit a `slog.Duration` as a number of -nanoseconds, matching `slog.NewJSONHandler`, so a consumer can compare and -aggregate the field without parsing it. The console line emits the same -duration as `3s`, matching `slog.NewTextHandler`, because a person reads -it. +**Durations are nanoseconds in json, and readable on the console.** The json and +webhook payloads emit a `slog.Duration` as a number of nanoseconds, matching +`slog.NewJSONHandler`, so a consumer can compare and aggregate the field without +parsing it. The console line emits the same duration as `3s`, matching +`slog.NewTextHandler`, because a person reads it. -**The json payload is an object, with the consequences an object has.** -The record's own fields are named `Time`, `Level`, `Message` and `PC`, and -they own those names: an attribute keyed after one of them is dropped from -the json and webhook output. A key logged more than once keeps its last -value there for the same reason, with one exception: when both are -`slog.Group` values sharing a key, the two groups are merged into one -object holding the members of both, rather than the second replacing the -first. Neither the collision nor the merge applies to the console output, -which is a line of text: every pair appears, in order. If you need a field -called `message`, pick a key that does not collide - the collision is -silent. +**The json payload is an object, with the consequences an object has.** The +record's own fields are named `Time`, `Level`, `Message` and `PC`, and they own +those names: an attribute keyed after one of them is dropped from the json and +webhook output. A key logged more than once keeps its last value there for the +same reason, with one exception: when both are `slog.Group` values sharing a +key, the two groups are merged into one object holding the members of both, +rather than the second replacing the first. Neither the collision nor the merge +applies to the console output, which is a line of text: every pair appears, in +order. If you need a field called `message`, pick a key that does not collide - +the collision is silent. -**Empty things follow the `slog.Handler` contract.** An empty `Attr` is -ignored, an empty group is elided along with its key, a group with an -empty key is inlined into its parent, and `WithGroup("")` returns the -handler unchanged. +**Empty things follow the `slog.Handler` contract.** An empty `Attr` is ignored, +an empty group is elided along with its key, a group with an empty key is +inlined into its parent, and `WithGroup("")` returns the handler unchanged. ## When delivery fails -Every handler returns an error from `Handle` when it could not deliver -the record: +Every handler returns an error from `Handle` when it could not deliver the +record: -- `ConsoleHandler` and `JSONHandler` return the error from their write to - stdout, wrapped, so `errors.Is` still matches the original -- `WebhookHandler` returns an error when the request fails, and also when - the server answers with a status outside 2xx, a redirect included, - since it does not follow redirects. A request that has not finished - after 5 seconds fails with a timeout error, so a webhook server that - never answers holds up a log call for 5 seconds at most -- `MultiplexHandler`, which simplelog installs as the default, passes the - record to every handler it holds even after one of them fails, then - returns all their errors joined with `errors.Join` (nil if none failed) +- `ConsoleHandler` and `JSONHandler` return the error from their write to + stdout, wrapped, so `errors.Is` still matches the original +- `WebhookHandler` returns an error when the request fails, and also when the + server answers with a status outside 2xx, a redirect included, since it does + not follow redirects. A request that has not finished after 5 seconds fails + with a timeout error, so a webhook server that never answers holds up a log + call for 5 seconds at most +- `MultiplexHandler`, which simplelog installs as the default, passes the record + to every handler it holds even after one of them fails, then returns all their + errors joined with `errors.Join` (nil if none failed) -`slog.Info`, `slog.Error` and the other `slog.Logger` methods throw that -error away. That is how `log/slog` works, and simplelog cannot change it. -To find out whether a record was delivered, build a `slog.Record` and pass -it to the handler yourself, for example -`slog.Default().Handler().Handle(ctx, record)`, then check the error it -returns. Console output takes the file and line from the record's `PC`, -which you set with `runtime.Callers` as the `log/slog` documentation +`slog.Info`, `slog.Error` and the other `slog.Logger` methods throw that error +away. That is how `log/slog` works, and simplelog cannot change it. To find out +whether a record was delivered, build a `slog.Record` and pass it to the handler +yourself, for example `slog.Default().Handler().Handle(ctx, record)`, then check +the error it returns. Console output takes the file and line from the record's +`PC`, which you set with `runtime.Callers` as the `log/slog` documentation shows. A record whose `PC` is zero prints `???:0` instead. ## Entrypoints @@ -143,8 +136,10 @@ alpine. We provide: - `script/bootstrap` — install all dependencies (go if missing, goimports with `go install` at a pinned commit unless the installed one already reports the - pinned version, then `go mod download`); golangci-lint is not installed, - since it runs only in Docker + pinned version, then `go mod download`; node through nvm at a pinned version + if node is missing, yarn at a pinned version if it is missing, then the + `prettier` that `package.json` and `yarn.lock` pin); golangci-lint is not + installed, since it runs only in Docker - `script/setup` — set up the repo for development after a fresh clone: runs `script/bootstrap`, then `script/install-precommit` - `script/projectname` — output the project name (our own extension); used by @@ -152,23 +147,23 @@ alpine. We provide: - `script/test` — run the test suite under the race detector in Docker by building only the `test` stage of the `Dockerfile`, without the build cache, so every run tests; the image is tagged `simplelog-test` -- `script/lint` — run golangci-lint in Docker by building only the `lint` - stage of the `Dockerfile`, without the build cache, so every run lints; the - image is tagged `simplelog-lint` -- `script/fmt` — format all files (writes) with the goimports that - `script/bootstrap` installs -- `script/fmt-check` — check formatting (read-only); fails if `gofmt -l` - reports files +- `script/lint` — run golangci-lint in Docker by building only the `lint` stage + of the `Dockerfile`, without the build cache, so every run lints; the image is + tagged `simplelog-lint` +- `script/fmt` — format all files (writes): Go with the goimports that + `script/bootstrap` installs, then every `*.md` file with `prettier` +- `script/fmt-check` — check formatting (read-only); fails if `gofmt -l` reports + files, or if `prettier` would change any `*.md` file - `script/check` — run all checks: `test`, `lint`, `fmt-check` (our own extension) -- `script/docker` — build the Docker image without the build cache, tagged - via `script/projectname` (byte-identical across repos); it also passes the - output of `git describe` as the `VERSION` build argument, which is ignored - because this `Dockerfile` does not declare it -- `script/cibuild` — what CI runs: `script/bootstrap`, then `script/check`, - then the same image build as `script/docker` (byte-identical across repos) -- `script/precommit` — run by the git pre-commit hook (our own extension); - runs a `go mod tidy` guard, then `script/check` +- `script/docker` — build the Docker image without the build cache, tagged via + `script/projectname` (byte-identical across repos); it also passes the output + of `git describe` as the `VERSION` build argument, which is ignored because + this `Dockerfile` does not declare it +- `script/cibuild` — what CI runs: `script/bootstrap`, then `script/check`, then + the same image build as `script/docker` (byte-identical across repos) +- `script/precommit` — run by the git pre-commit hook (our own extension); runs + a `go mod tidy` guard, then `script/check` - `script/install-precommit` — installs the git pre-commit hook (our own extension); `make hooks` shims to it diff --git a/TODO.md b/TODO.md index bbd37ae..2baadad 100644 --- a/TODO.md +++ b/TODO.md @@ -1,86 +1,89 @@ # Workflow -* branch (from `main`) -* do the work in Next Step -* move Next Step to the top of Completed Steps -* move the top item of Future Steps into Next Step -* commit (`TODO.md` changes in the same commit as the work) -* merge to `main` if the branch is not protected, otherwise open a PR -* push +- branch (from `main`) +- do the work in Next Step +- move Next Step to the top of Completed Steps +- move the top item of Future Steps into Next Step +- commit (`TODO.md` changes in the same commit as the work) +- merge to `main` if the branch is not protected, otherwise open a PR +- push # Status 1.0+ -Tagged v1.0.0 (2024-06-14) and 1.0.1 (2026-02-08). In post-1.0 -maintenance; the library is referenced by the Go styleguide. +Tagged v1.0.0 (2024-06-14) and 1.0.1 (2026-02-08). In post-1.0 maintenance; the +library is referenced by the Go styleguide. # Next Step -Restructure README.md into the standard sections: Description, Getting -Started, Rationale, Design, TODO, License, Author +Restructure README.md into the standard sections: Description, Getting Started, +Rationale, Design, TODO, License, Author # Completed Steps -* 2026-10-06: re-vendored the standard files from `sneak/prompts` at - commit `dd4027b`: `REPO_POLICIES.md`, the CI workflow, `.gitignore`, - and `.golangci.yml` together with the lint stage's new image, plus - the `.editorconfig` and `.dockerignore` this repo lacked, which - finishes the Makefile and policy-files step. `script/cibuild` now - bootstraps and runs `script/check` before building the image, the - format check runs only on the host, the last `Dockerfile` stage runs - `script/bootstrap`, which installs goimports at a pinned commit -* 2026-10-06: a webhook request now times out after 5 seconds, so a - server that never answers no longer stops every log call; the webhook - handler also reads each answer to the end so its connection is reused -* 2026-10-06: the console handler takes the file and line it prints from - the record's `PC` instead of counting stack frames, so they name the - call site whether the record came through a `slog.Logger` or straight - to `Handle` -* 2026-10-06: the tests run under the race detector, in Docker: - `script/test` builds the `test` stage of the `Dockerfile`, which runs - `go test -race`, and a new final stage makes a plain `docker build` - run both the `lint` and `test` stages -* 2026-10-06: the linter runs only in Docker: `script/lint` builds the - `lint` stage of the `Dockerfile`, every `docker build` in `script/` - runs without the build cache, and `script/bootstrap` no longer - installs golangci-lint -* 2026-10-06: every handler now returns a failed delivery from `Handle` - instead of discarding it: console and JSON return the stdout write - error, the webhook also fails on a non-2xx answer, and the multiplex - delivers to every handler and returns their errors joined -* 2026-08-10: fixed every handler discarding slog attributes: console, - JSON and webhook handlers now emit record attributes, accumulate - WithAttrs without mutating the receiver, and honour WithGroup; - slog.Group values nest and LogValuer values are resolved; console - keys and values holding invalid UTF-8 are quoted -* 2026-08-07: added canonical `.golangci.yml` (v2 schema), pinned the - `Dockerfile` lint stage to golangci-lint v2.12.2 (tag+digest), and - fixed all findings the v1→v2 jump surfaced without changing any - exported signatures or behavior -- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints, - Makefile shims, README Entrypoints section -* 2026-02-08: fixed JSONHandler deadlock from recursive log.Println, - with regression test; tagged 1.0.1 -* 2024-06-14: 1.0 prep: lint and fmt enforced in Docker build, call - stack depth fix so log locations report correctly, example script, - removed non-building RELP code; tagged v1.0.0 -* 2024-05-22: module path moved to sneak.berlin/go/simplelog -* 2024-05-14: initial library: slog-based console, JSON, webhook, and - RELP handlers, MultiplexHandler, caller file/line info, UTC ISO - timestamps, level-colored output +- 2026-10-06: `make fmt` and `make fmt-check` also format Markdown, with + `prettier` at the standard settings, pinned through `package.json` and + `yarn.lock`; `script/bootstrap` installs node and yarn for it, and the + existing Markdown was formatted once +- 2026-10-06: re-vendored the standard files from `sneak/prompts` at commit + `dd4027b`: `REPO_POLICIES.md`, the CI workflow, `.gitignore`, and + `.golangci.yml` together with the lint stage's new image, plus the + `.editorconfig` and `.dockerignore` this repo lacked, which finishes the + Makefile and policy-files step. `script/cibuild` now bootstraps and runs + `script/check` before building the image, the format check runs only on the + host, the last `Dockerfile` stage runs `script/bootstrap`, which installs + goimports at a pinned commit +- 2026-10-06: a webhook request now times out after 5 seconds, so a server that + never answers no longer stops every log call; the webhook handler also reads + each answer to the end so its connection is reused +- 2026-10-06: the console handler takes the file and line it prints from the + record's `PC` instead of counting stack frames, so they name the call site + whether the record came through a `slog.Logger` or straight to `Handle` +- 2026-10-06: the tests run under the race detector, in Docker: `script/test` + builds the `test` stage of the `Dockerfile`, which runs `go test -race`, and a + new final stage makes a plain `docker build` run both the `lint` and `test` + stages +- 2026-10-06: the linter runs only in Docker: `script/lint` builds the `lint` + stage of the `Dockerfile`, every `docker build` in `script/` runs without the + build cache, and `script/bootstrap` no longer installs golangci-lint +- 2026-10-06: every handler now returns a failed delivery from `Handle` instead + of discarding it: console and JSON return the stdout write error, the webhook + also fails on a non-2xx answer, and the multiplex delivers to every handler + and returns their errors joined +- 2026-08-10: fixed every handler discarding slog attributes: console, JSON and + webhook handlers now emit record attributes, accumulate WithAttrs without + mutating the receiver, and honour WithGroup; slog.Group values nest and + LogValuer values are resolved; console keys and values holding invalid UTF-8 + are quoted +- 2026-08-07: added canonical `.golangci.yml` (v2 schema), pinned the + `Dockerfile` lint stage to golangci-lint v2.12.2 (tag+digest), and fixed all + findings the v1→v2 jump surfaced without changing any exported signatures or + behavior + +* 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints, Makefile + shims, README Entrypoints section + +- 2026-02-08: fixed JSONHandler deadlock from recursive log.Println, with + regression test; tagged 1.0.1 +- 2024-06-14: 1.0 prep: lint and fmt enforced in Docker build, call stack depth + fix so log locations report correctly, example script, removed non-building + RELP code; tagged v1.0.0 +- 2024-05-22: module path moved to sneak.berlin/go/simplelog +- 2024-05-14: initial library: slog-based console, JSON, webhook, and RELP + handlers, MultiplexHandler, caller file/line info, UTC ISO timestamps, + level-colored output # Future Steps -* Delete the old plain TODO file once this TODO.md lands -* Add .aider.* to .gitignore and remove the stray aider artifacts from - the working tree -* Pick one tag scheme before the next release (v1.0.0 vs 1.0.1 are - inconsistent) -* Tag v1.0.2, with the leading v, once the attribute fix lands, so - consuming repos can move off pseudo-version pins in one step -* Fix RELP output to cache (from old TODO) -* Re-add RELP delivery over TCP to remote rsyslog imrelp; removed - 2024-06-14 because it did not build (README planned feature) -* Add regex filtering for webhook logs (from old TODO) -* Better console output format (from old TODO) +- Delete the old plain TODO file once this TODO.md lands +- Add .aider.\* to .gitignore and remove the stray aider artifacts from the + working tree +- Pick one tag scheme before the next release (v1.0.0 vs 1.0.1 are inconsistent) +- Tag v1.0.2, with the leading v, once the attribute fix lands, so consuming + repos can move off pseudo-version pins in one step +- Fix RELP output to cache (from old TODO) +- Re-add RELP delivery over TCP to remote rsyslog imrelp; removed 2024-06-14 + because it did not build (README planned feature) +- Add regex filtering for webhook logs (from old TODO) +- Better console output format (from old TODO) diff --git a/package.json b/package.json new file mode 100644 index 0000000..fb8b622 --- /dev/null +++ b/package.json @@ -0,0 +1,6 @@ +{ + "private": true, + "devDependencies": { + "prettier": "3.8.1" + } +} diff --git a/script/bootstrap b/script/bootstrap index 02277dc..3d10d5a 100755 --- a/script/bootstrap +++ b/script/bootstrap @@ -2,11 +2,21 @@ # 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 tooling comes from nix, apt, brew, -# or apk (detected in that order); assumes nothing is present. +# or apk (detected in that order); assumes nothing is present. Node is +# used directly if installed; otherwise it is installed at a pinned +# version via nvm (installing nvm itself first, from a hash-verified +# release archive, never curl | sh). set -eu ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" +# Pinned versions, 2026-07-06 +NODE_VERSION="22.17.0" +NVM_VERSION="0.40.3" +# sha256 of https://github.com/nvm-sh/nvm/archive/refs/tags/v0.40.3.tar.gz +NVM_SHA256="5f4d6aaa04a177dc93c985e31dbc411ab6b8c6e1e21d8015dbc1372625fcd1d0" +YARN_VERSION="1.22.22" + # goimports from golang.org/x/tools v0.30.0, 2025-02-10: the last release # that Go 1.22, the Go of go.mod and the Dockerfile, can build. GOIMPORTS_COMMIT="09747cdf594a7924dcecb506312be3bd6e437962" @@ -81,6 +91,69 @@ ensure_goimports() { echo "goimports $GOIMPORTS_VERSION at $(command -v goimports)" } +# verify_sha256 +verify_sha256() { + if command -v sha256sum >/dev/null 2>&1; then + actual="$(sha256sum "$1" | cut -d' ' -f1)" + else + actual="$(shasum -a 256 "$1" | cut -d' ' -f1)" + fi + if [ "$actual" != "$2" ]; then + echo "bootstrap: sha256 mismatch for $1" >&2 + echo " expected: $2" >&2 + echo " actual: $actual" >&2 + exit 1 + fi +} + +# nvm is a bash script; run a command in a bash with nvm loaded +nvm_sh() { + bash -c ". \"\$HOME/.nvm/nvm.sh\" && $*" +} + +ensure_nvm() { + [ -s "$HOME/.nvm/nvm.sh" ] && return 0 + # nvm prerequisites; nvm itself requires bash + if missing bash; then pkg_install bash bash bash bash; fi + if missing curl; then pkg_install curl curl curl curl; fi + if missing git; then pkg_install git git git git; fi + tmp="$(mktemp -d)" + curl -fsSL -o "$tmp/nvm.tar.gz" \ + "https://github.com/nvm-sh/nvm/archive/refs/tags/v${NVM_VERSION}.tar.gz" + verify_sha256 "$tmp/nvm.tar.gz" "$NVM_SHA256" + mkdir -p "$HOME/.nvm" + tar -xzf "$tmp/nvm.tar.gz" -C "$HOME/.nvm" --strip-components=1 + rm -rf "$tmp" +} + +ensure_node() { + if ! missing node; then return 0; fi + ensure_nvm + nvm_sh "nvm install $NODE_VERSION" +} + +ensure_yarn() { + if ! missing yarn; then return 0; fi + if ! missing corepack; then + corepack enable + corepack prepare "yarn@$YARN_VERSION" --activate + elif [ -s "$HOME/.nvm/nvm.sh" ]; then + nvm_sh "nvm use $NODE_VERSION >/dev/null && corepack enable && \ + corepack prepare yarn@$YARN_VERSION --activate" + else + npm install -g "yarn@$YARN_VERSION" + fi +} + +install_js_deps() { + if missing yarn && [ -s "$HOME/.nvm/nvm.sh" ]; then + nvm_sh "nvm use $NODE_VERSION >/dev/null && cd \"$ROOT\" && \ + yarn install --frozen-lockfile" + else + yarn install --frozen-lockfile + fi +} + main() { cd "$ROOT" @@ -93,6 +166,10 @@ main() { go mod download + ensure_node + ensure_yarn + install_js_deps + echo "bootstrap complete" } diff --git a/script/fmt b/script/fmt index 0685452..16c6159 100755 --- a/script/fmt +++ b/script/fmt @@ -1,17 +1,37 @@ #!/bin/sh -# script/fmt: format all files (writes) with the goimports that -# script/bootstrap installs. It puts Go's bin directory first on PATH, as -# script/bootstrap does, so it runs that goimports even when the directory -# is not on the caller's PATH. +# script/fmt: format all files (writes): Go with the goimports that +# script/bootstrap installs, then Markdown with prettier. It puts Go's bin +# directory first on PATH, as script/bootstrap does, so it runs that +# goimports even when the directory is not on the caller's PATH. set -eu ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" +# Must match the pin in script/bootstrap. +NODE_VERSION="22.17.0" + +# script/bootstrap installs node and yarn under nvm and leaves neither +# on the PATH of the shell that called it, so resolve the pinned +# toolchain here the way bootstrap's own install step does. nvm is a +# bash script, hence the subshell. +run_yarn() { + if command -v yarn >/dev/null 2>&1; then + exec yarn "$@" + fi + if [ ! -s "$HOME/.nvm/nvm.sh" ]; then + echo "fmt: no yarn; run script/bootstrap first" >&2 + exit 1 + fi + exec bash -c '. "$HOME/.nvm/nvm.sh" && nvm use "$1" >/dev/null && + shift && exec yarn "$@"' bash "$NODE_VERSION" "$@" +} + main() { cd "$ROOT" gobin="$(go env GOBIN)" PATH="${gobin:-$(go env GOPATH)/bin}:$PATH" goimports -l -w . + run_yarn run prettier --write '**/*.md' --tab-width 4 --prose-wrap always } main "$@" diff --git a/script/fmt-check b/script/fmt-check index 6a64782..4fbd6b4 100755 --- a/script/fmt-check +++ b/script/fmt-check @@ -1,10 +1,30 @@ #!/bin/sh # script/fmt-check: check formatting (read-only). Fails and lists the -# offending files if gofmt would reformat anything. +# offending files if gofmt would reformat any Go file; otherwise fails if +# prettier would reformat any Markdown file. set -eu ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" +# Must match the pin in script/bootstrap. +NODE_VERSION="22.17.0" + +# script/bootstrap installs node and yarn under nvm and leaves neither +# on the PATH of the shell that called it, so resolve the pinned +# toolchain here the way bootstrap's own install step does. nvm is a +# bash script, hence the subshell. +run_yarn() { + if command -v yarn >/dev/null 2>&1; then + exec yarn "$@" + fi + if [ ! -s "$HOME/.nvm/nvm.sh" ]; then + echo "fmt-check: no yarn; run script/bootstrap first" >&2 + exit 1 + fi + exec bash -c '. "$HOME/.nvm/nvm.sh" && nvm use "$1" >/dev/null && + shift && exec yarn "$@"' bash "$NODE_VERSION" "$@" +} + main() { cd "$ROOT" unformatted="$(gofmt -l .)" @@ -13,6 +33,7 @@ main() { echo "$unformatted" exit 1 fi + run_yarn run prettier --check '**/*.md' --tab-width 4 --prose-wrap always } main "$@" diff --git a/yarn.lock b/yarn.lock new file mode 100644 index 0000000..d846639 --- /dev/null +++ b/yarn.lock @@ -0,0 +1,8 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +prettier@3.8.1: + version "3.8.1" + resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.8.1.tgz#edf48977cf991558f4fcbd8a3ba6015ba2a3a173" + integrity sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg== -- 2.54.0