diff --git a/Dockerfile b/Dockerfile index 020f59f..4cfba6d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,35 +1,43 @@ -# Build stage -# golang 1.25-alpine, 2026-02-28 -FROM golang@sha256:f6751d823c26342f9506c03797d2527668d095b0a15f1862cddb4d927a7a4ced AS builder - -RUN apk add --no-cache git make gcc musl-dev binutils-gold - -# golangci-lint v2.12.2, 2026-10-05 -RUN go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@c0d3ddc9cf3faa61a4e378e879ece580256d76e5 -# goimports v0.42.0 -RUN go install golang.org/x/tools/cmd/goimports@009367f5c17a8d4c45a961a3a509277190a9a6f0 - +# Lint phase +# golangci/golangci-lint:v2.12.2, 2026-10-05 +FROM golangci/golangci-lint@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS lint WORKDIR /src COPY go.mod go.sum ./ RUN go mod download +COPY . . +RUN golangci-lint run --config .golangci.yml ./... +# Test phase. -race needs cgo and so a C compiler, which the Debian Go +# image ships and the alpine one does not. The tests run as an +# unprivileged user: root can read a file with mode 0000, so the +# permission test would fail. +# golang:1.25.7-trixie, 2026-10-06 +FROM golang@sha256:2b174ffcf56c7ad0c47d30d2630693265639ddf2a5141149c2da34db921791b4 AS test +RUN useradd --create-home testuser +USER testuser +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN go test -timeout 90s -race -cover ./... || \ + { echo "--- Rerunning with -v for details ---"; \ + go test -timeout 90s -race -v ./...; exit 1; } + +# Build stage. Nothing is wanted from either phase above; the copies +# are what make BuildKit build them first, so this stage cannot run +# unless lint and test passed. +# golang 1.25-alpine, 2026-02-28 +FROM golang@sha256:f6751d823c26342f9506c03797d2527668d095b0a15f1862cddb4d927a7a4ced AS builder +COPY --from=lint /src/go.sum /dev/null +COPY --from=test /src/go.sum /dev/null +RUN apk add --no-cache git make +# 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 . . -# Run the checks as an unprivileged user. Root bypasses file mode bits, which -# would make the permission tests (expecting EACCES on a 0000 file) spuriously -# pass with no error. Caches live under /tmp (world-writable) so the user needs -# no home directory of its own. -ENV GOCACHE=/tmp/gocache -ENV XDG_CACHE_HOME=/tmp/xdgcache -RUN adduser -D -u 1000 builder && chown -R builder:builder /src /go -USER builder - -# Run all checks - build fails if any check fails -RUN make check - -# Build the binary (still as the unprivileged user: it owns /src, so git VCS -# stamping sees consistent ownership). -# # The version stamped into the binary: the VERSION build argument when one # is given, otherwise `git describe --tags --always` of the .git the build # context carries: the tag on a tagged commit, tag-N-gHASH on a commit after diff --git a/README.md b/README.md index dcfb598..e5ad7d3 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,9 @@ Future improvements under consideration: * Author & maintainer: **sneak** – * Issues / PRs: -* Code must pass `go vet`, `go test ./...`, and `go fmt`. +* Code must pass `make check`, which runs the tests and golangci-lint as + phases of the `Dockerfile` (Docker is required) and checks formatting + with `gofmt`. * No CLA; contributions are under WTFPL v2. --- diff --git a/TODO.md b/TODO.md index bc56565..4d9a89a 100644 --- a/TODO.md +++ b/TODO.md @@ -17,13 +17,19 @@ have landed since the tag. # Next Step -Policy scaffold commit: add LICENSE, REPO_POLICIES.md, .editorconfig, -.golangci.yml, and a comprehensive .gitignore (currently only the -attrsum binary), and extend the Makefile (only test/build/clean/try -today) with lint, fmt, fmt-check, check, and hooks targets. +Re-vendor the canonical files from `sneak/prompts` at `dd4027b` +(https://git.eeqj.de/sneak/attrsum/issues/13): add `REPO_POLICIES.md` +and `.editorconfig`, refresh `.gitignore` (only the `attrsum` binary +today), `.dockerignore`, `.gitea/workflows/check.yml` and +`.golangci.yml`, and move the lint phase to golangci-lint v2.14.0. # Completed Steps +* 2026-10-06: lint and test run as phases of the `Dockerfile`, and the + build stage depends on both; `script/lint` and `script/test` each + build their phase with `--no-cache`; `script/cibuild` bootstraps, + runs `script/check`, then builds the image; golangci-lint is no + longer installed on the host * 2026-10-05: golangci-lint settings take effect: canonical `.golangci.yml` (v2 layout, settings under `linters.settings`), golangci-lint pinned at v2.12.2 in `Dockerfile` and @@ -50,11 +56,12 @@ today) with lint, fmt, fmt-check, check, and hooks targets. # Future Steps -* Add .gitea/workflows/check.yml * Restructure README.md into the standard sections: Description, - Getting Started, Rationale, Design, TODO, License, Author (Getting - Started, Why?, TODO, License exist; Description, Design, Author are - missing) + Getting Started, Entrypoints, Rationale, Design, TODO, License, + Author (Getting Started, Why?, TODO, License exist; Description, + Entrypoints, Design, Author are missing) +* Add a `LICENSE` file matching the README's WTFPL v2; sneak's to add, + not an agent's * Tag a patch release to ship the 2026-02-02 correctness fixes * Dry-run mode (--dry-run, -n): show what would be done without making changes (from README TODO) diff --git a/script/bootstrap b/script/bootstrap index 1d6577e..f7f985f 100755 --- a/script/bootstrap +++ b/script/bootstrap @@ -3,15 +3,14 @@ # 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. -# golangci-lint and goimports are installed via `go install` at the same -# pinned commits the Dockerfile uses (never "latest"). +# goimports is installed via `go install` at a pinned commit (never +# "latest"). The linter is not installed: it runs only as the lint phase +# of the Dockerfile. set -eu ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" -# Pinned versions, 2026-10-05 (same pins as the Dockerfile) -# golangci-lint v2.12.2 -GOLANGCI_LINT_REF="github.com/golangci/golangci-lint/v2/cmd/golangci-lint@c0d3ddc9cf3faa61a4e378e879ece580256d76e5" +# Pinned versions, 2026-10-05 # goimports v0.42.0 GOIMPORTS_REF="golang.org/x/tools/cmd/goimports@009367f5c17a8d4c45a961a3a509277190a9a6f0" @@ -69,9 +68,8 @@ main() { if missing make; then pkg_install gnumake make make make; fi if missing go; then pkg_install go golang go go; fi - # Lint/format tools, pinned via go install (installs into + # Format tool, pinned via go install (installs into # "$(go env GOPATH)/bin"; ensure that is on your PATH). - if missing golangci-lint; then go install "$GOLANGCI_LINT_REF"; fi if missing goimports; then go install "$GOIMPORTS_REF"; fi 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 966f51d..d8d3200 100755 --- a/script/cibuild +++ b/script/cibuild @@ -1,13 +1,28 @@ #!/bin/sh -# script/cibuild: run the CI build. The Dockerfile runs make check, so -# a successful build implies all checks pass. +# 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 -ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" +ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)" main() { cd "$ROOT" - docker build . + "$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/lint b/script/lint index 8017180..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 --config .golangci.yml ./... + docker build --no-cache \ + --target lint \ + -t "$("$SCRIPT_DIR/projectname")-lint" . } main "$@" diff --git a/script/test b/script/test index 50b1730..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 -race -timeout 30s -cover ./... + docker build --no-cache \ + --target test \ + -t "$("$SCRIPT_DIR/projectname")-test" . } main "$@"