diff --git a/.dockerignore b/.dockerignore index 26123bb..d5bb375 100644 --- a/.dockerignore +++ b/.dockerignore @@ -17,7 +17,10 @@ # 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. .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. diff --git a/.gitea/workflows/check.yml b/.gitea/workflows/check.yml index ee73864..ce6d662 100644 --- a/.gitea/workflows/check.yml +++ b/.gitea/workflows/check.yml @@ -6,4 +6,7 @@ jobs: steps: # actions/checkout v4.2.2, 2026-02-22 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + # All history and tags, which `git describe --tags` needs. + fetch-depth: 0 - run: script/cibuild diff --git a/.golangci.yml b/.golangci.yml index a7a74c2..1b73eb9 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -17,6 +17,7 @@ linters: disable: # Genuinely incompatible with project patterns - exhaustruct # Requires all struct fields + - exhaustruct_v5 # Requires all struct fields (successor to exhaustruct) - godot # Requires comments to end with periods - wrapcheck # Too verbose for internal packages - varnamelen # Short names like db, id are idiomatic Go diff --git a/Dockerfile b/Dockerfile index 2f33928..812c833 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,11 +1,11 @@ -# The lint phase, the test phase and the build. script/lint and -# script/test each build one phase alone; a plain `docker build .` builds -# both, because the build stage copies a file from each. Formatting is -# checked on the host by script/fmt-check, not here. +# The lint phase, the test phase and a development environment. +# script/lint and script/test each build one phase alone; a plain +# `docker build .` builds both, because the last stage copies a file from +# each. Formatting is checked on the host by script/fmt-check, not here. # Lint phase -# golangci/golangci-lint:v2.12.2, 2026-09-07 -FROM golangci/golangci-lint@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS lint +# golangci/golangci-lint:v2.14.0, 2026-10-04 +FROM golangci/golangci-lint@sha256:ad862ba6b3798cbe0fd9fd7408d498fd74fbd2623a92406b2fd3898faf0bf98f AS lint WORKDIR /src @@ -16,13 +16,12 @@ COPY . . RUN golangci-lint run --config .golangci.yml ./... -# Test phase -# golang:1.26-alpine, 2026-09-07. It carries Go 1.26.8, the version -# script/bootstrap installs on the host; change both together. -FROM golang@sha256:ce864e7223ac17b1775e6fd0b4c0db580c2eb50e7953a427916379e4b92a1628 AS test - -# -race needs cgo, and cgo needs a C toolchain. -RUN apk add --no-cache gcc musl-dev +# Test phase. -race needs cgo and so a C compiler, which the Debian Go +# image ships. +# golang:1.26.8-trixie, 2026-10-04. It carries Go 1.26.8, the version +# script/bootstrap installs on the host; change both together, and the +# same image in the last stage. +FROM golang@sha256:eae2aaa6add2936cbf350dd0d2628b363461542f0c4b3c0b558957e0f2997379 AS test WORKDIR /src @@ -35,21 +34,27 @@ 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.26-alpine, 2026-09-07 -FROM golang@sha256:ce864e7223ac17b1775e6fd0b4c0db580c2eb50e7953a427916379e4b92a1628 AS builder +# Development environment, and the last stage: a plain `docker build .` +# builds this one. It holds the source tree in /src, what +# script/bootstrap installs, and keyfunc built from that tree on the +# PATH. 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.26.8-trixie, 2026-10-04 +FROM golang@sha256:eae2aaa6add2936cbf350dd0d2628b363461542f0c4b3c0b558957e0f2997379 COPY --from=lint /src/go.sum /dev/null COPY --from=test /src/go.sum /dev/null -RUN apk add --no-cache make 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 +# script/bootstrap needs only script/ and the dependency manifests. +COPY script/ script/ +COPY go.mod go.sum package.json yarn.lock ./ +RUN script/bootstrap COPY . . @@ -64,11 +69,4 @@ RUN version="${VERSION:-$(git describe --tags --always || echo dev)}"; \ echo "no version could be derived although the build context carries .git" >&2; \ exit 1; \ fi; \ - make build VERSION="$version" - -# alpine:3.23, 2026-09-07 -FROM alpine@sha256:fd791d74b68913cbb027c6546007b3f0d3bc45125f797758156952bc2d6daf40 - -COPY --from=builder /src/keyfunc /usr/local/bin/keyfunc - -ENTRYPOINT ["keyfunc"] + make build VERSION="$version" && mv keyfunc /usr/local/bin/keyfunc diff --git a/README.md b/README.md index 3f9ff56..98a53ba 100644 --- a/README.md +++ b/README.md @@ -331,7 +331,10 @@ standard: most Makefile targets are thin shims over an executable in `script/` - `script/docker` builds the Docker image, uncached, tagged with the project name and stamped with the version `git describe` gives on the host. The image cannot be built unless the `lint` and `test` phases pass, so a plain - `docker build .` runs them too. + `docker build .` runs them too. The image is a development environment, not a + runtime image: the Debian Go image with what `script/bootstrap` installs, the + source tree in `/src`, and `keyfunc` built from it on the `PATH`. + `docker run --rm -it keyfunc` opens a shell in it. - `script/cibuild` is the CI build the Gitea workflow calls: it runs `bootstrap`, then `check`, then builds the image as `script/docker` does. - `script/precommit` is what the git pre-commit hook runs: `go mod tidy` and diff --git a/REPO_POLICIES.md b/REPO_POLICIES.md index ca05cb4..af0ea80 100644 --- a/REPO_POLICIES.md +++ b/REPO_POLICIES.md @@ -1,6 +1,6 @@ --- title: Repository Policies -last_modified: 2026-10-02 +last_modified: 2026-10-04 --- This document covers repository structure, tooling, and workflow standards. Code @@ -160,7 +160,7 @@ style conventions are in separate documents: - **The gate phases are separate stages, and the build stage depends on both.** The lint phase is based on the `golangci/golangci-lint` image (pinned by hash), so lint failures surface in seconds rather than after a full compile, - and the test phase is based on the Go image. The canonical Go repo + and the test phase is based on the Debian Go image. The canonical Go repo `Dockerfile`: ```dockerfile @@ -173,8 +173,9 @@ style conventions are in separate documents: COPY . . RUN golangci-lint run --config .golangci.yml ./... - # Test phase - # golang:1.x-alpine, YYYY-MM-DD + # Test phase. -race needs cgo and so a C compiler, which the Debian Go + # image ships and the alpine one does not. + # golang:1.x, YYYY-MM-DD FROM golang@sha256:... AS test WORKDIR /src COPY go.mod go.sum ./ @@ -192,6 +193,8 @@ style conventions are in separate documents: 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 @@ -236,19 +239,23 @@ style conventions are in separate documents: - If the project requires CGO or system libraries for linting (e.g. `vips-dev`), install them in the lint phase with `apk add`. - `.dockerignore` lets `.git` into the build context. It keeps out - `.git/config`, which `git describe` does not need and which can hold a - credential: a password in a remote URL, or the token the CI checkout step - stores there. 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. `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. + `.git/config` and each submodule's `config` under `.git/modules/` at any + depth (`.git/modules/**/config`), which `git describe` does not need and + which can hold a credential: a password in a remote URL, or the token the + CI checkout step stores there. 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` on push, and checks out the repo as its only other step. @@ -310,17 +317,19 @@ style conventions are in separate documents: ``` `-count=1` is required on both invocations: it defeats Go's test _result_ - cache, so the target cannot report a pass it did not earn, and the rerun - reproduces a failure instead of replaying it. It leaves the build cache - alone, so it costs the runtime of the suite and no recompilation. + cache, so neither run can report a stored pass in place of running the + tests. It leaves the build cache alone, so it costs the runtime of the suite + and no recompilation. - Note that this is a second, independent cache, stacked below the Docker - layer cache that [issue #26](https://git.eeqj.de/sneak/prompts/issues/26) - addresses. `CHECK_EPOCH` guarantees the `RUN make test` _step_ re-executes; - it does not guarantee `go test` inside that step does any work, because the - `GOCACHE` baked into earlier image layers survives into the re-executed - step. They are two separate defects requiring two separate fixes, and a fix - for one must not be recorded as covering the other. + That cache is Go's own, separate from Docker's layer cache. Go stores a + passing result in its cache directory (`GOCACHE`), and when the same tests + run again on unchanged code it prints that result, marked `(cached)`, + without running them. 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: @@ -451,12 +460,18 @@ style conventions are in separate documents: `test-support` depguard rule, where a repo names its own test-support packages by full import path. A repo adds entries there and changes nothing else, and a re-vendor carries its entries forward. The canonical golangci-lint version is - v2.12.2 (released 2026-05-06), pinned as the digest of the lint phase's base + v2.14.0 (released 2026-09-24), pinned as the digest of the lint phase's base image - (`golangci/golangci-lint@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240`, - which reports `2.12.2 built with go1.26.2 from c0d3ddc9`). That digest is the - only pin, since no repo installs golangci-lint on the host: bumping the - version means changing it and nothing else. + (`golangci/golangci-lint@sha256:ad862ba6b3798cbe0fd9fd7408d498fd74fbd2623a92406b2fd3898faf0bf98f`, + which reports `2.14.0 built with go1.27.0 from 114493f9`). A module's `go` + directive must not name a newer Go minor version than the one golangci-lint + was built with, or golangci-lint refuses to lint it: this release lints + `go 1.27.1` but not `go 1.28`. That digest is the only pin, since no repo + installs golangci-lint on the host. A repo sets the lint phase digest to the + one named here and re-vendors `.golangci.yml` in the same commit, whichever of + the two prompted the change: the canonical copy can name linters that an older + golangci-lint rejects, and a newer golangci-lint can add linters that + `default: all` switches on until the canonical copy disables them. - **`script/bootstrap` installs a pinned tool by comparing versions, never by testing presence.** An `if ! command -v ; then install; fi` guard tests @@ -592,10 +607,10 @@ 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; thin only: one `main.go` per binary whose body is a single call into `internal/` or `pkg/`, no project logic in @@ -626,3 +641,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/internal/cli/cli.go b/internal/cli/cli.go index e49c853..37ee347 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -86,8 +86,7 @@ func Main() int { return 0 } - var passed ssh.StatusError - if errors.As(err, &passed) { + if passed, ok := errors.AsType[ssh.StatusError](err); ok { return passed.Status } diff --git a/internal/cli/ssh/to.go b/internal/cli/ssh/to.go index 6569c5c..c36ec88 100644 --- a/internal/cli/ssh/to.go +++ b/internal/cli/ssh/to.go @@ -88,8 +88,7 @@ func connect(ctx context.Context, argv []string) error { return nil } - var ended *exec.ExitError - if errors.As(err, &ended) { + if ended, ok := errors.AsType[*exec.ExitError](err); ok { status := ended.ExitCode() if status < 0 { // A signal ended ssh, and a signal has no status of its