A golangci-lint result on a host running many concurrent workers does not reliably belong to the tree that asked for it. Two independent mechanisms, which have repeatedly been mistaken for one: The result cache is keyed on file content, not location, so two checkouts of the same commit hold byte-identical files, share cache entries, and one tree's findings are served for the other under the other tree's path. This produced a confirmed false green as well as the loud false reds. Moving workers from shared worktrees to their own clones does not address it — two clones collide exactly as two worktrees did — and removes only the foreign-path artefact that made the defect noticeable. The concurrency lock is $TMPDIR/golangci-lint.lock (pkg/commands/run.go, acquireFileLock), host-global and independent of GOLANGCI_LINT_CACHE, with a five-second acquire timeout, so it fails when the host is busiest. A private cache directory does not isolate it. Setting only the cache closes the contamination half and leaves runs failing red on a condition that is not a result at all. REPO_POLICIES.md now carries the canonical Go script/lint: both variables scoped into a .lint-cache/ directory inside the checkout, above any container-versus-host branch so every path reaching the linter gets them; --allow-serial-runners, which keeps the mutual-exclusion guard and queues rather than aborting, for the same-checkout overlap TMPDIR scoping cannot cover, with --allow-parallel-runners rejected because it deletes the guard; and a bounded retry that treats the lock error as VOID rather than as findings, exiting 75 on exhaustion so it is neither a pass nor a failure. Detection is on the stderr stream and never on exit status: findings go to stdout, so a finding quoting the lock message in source cannot be retried away, and the exit status is not a stable discriminator anyway. The interim void rule is recorded with the ../ clause that the original filter missed, and with its limit stated — it catches contamination that names foreign files, not contamination that suppresses findings. Both checklists gained the corresponding items, since a half-fix that sets only the cache reads as complete. GOCACHE was measured rather than assumed and does not need isolating: with the two variables scoped per checkout and GOCACHE shared at the host default, each checkout reported its own paths. Verified with the snippet extracted from the committed document and executed as a consuming repo would adopt it, each control paired against the pre-fix form: contamination reproduced on the pre-fix script and absent on the adopted one; a stub linter colliding twice then clearing, with the retry engaging and succeeding; exhaustion exiting 75 with a VOID message; a genuine finding whose text quotes the lock message reported as findings with no retry; and a real held lock failing the pre-fix script with exit 3 while the adopted script, inheriting the same environment, completed in one second.
66 KiB
title, last_modified
| title | last_modified |
|---|---|
| Repository Policies | 2026-08-09 |
This document covers repository structure, tooling, and workflow standards. Code style conventions are in separate documents:
- Code Styleguide (general, bash, Docker)
- Go
- JavaScript
- Python
- Go HTTP Server Conventions
-
Cross-project documentation (such as this file) must include
last_modified: YYYY-MM-DDin 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 ingo.sum, npm integrity hash in lockfile, GitHub Actions@<commit-sha>). No exceptions. This also means nevercurl | bashto 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
Makefilewith these targets:make bootstrap,make setup,make test,make lint,make fmt(writes),make fmt-check(read-only),make check(runstest,lint,fmt-check),make docker, andmake hooks(installs pre-commit hook). A model Makefile is athttps://git.eeqj.de/sneak/prompts/raw/branch/main/Makefile. -
Repos follow the 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)andcdthere before acting. From the standard's canonical set we usebootstrap,setup(make the repo ready for development after a fresh clone: runsbootstrap, theninstall-precommit, plus any repo-specific initialization),test, andcibuild.script/bootstrapinstalls 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 (nevercurl | sh), with bash installed as an explicit prerequisite since nvm requires bash. yarn is then pinned viacorepack prepare yarn@<version> --activate. Never install "latest" or "lts"; always exact versions.script/cibuildruns the CI build: it changes to the repo root and runsdocker build --build-arg CHECK_EPOCH="$epoch" --build-arg VERSION="$version" ., whereepochis a per-invocation nonce (see theCHECK_EPOCHrule below) andversionis computed on the host because.gitis not in the build context (see the git-describe rule below); the Gitea workflow calls it. Four further scripts are our own extensions to the standard:script/checkrunsscript/test,script/lint, andscript/fmt-check;script/precommitis what the git pre-commit hook runs, and it callsscript/check;script/install-precommitinstalls the git pre-commit hook (themake hookstarget shims to it); andscript/projectname(literally that filename) simply outputs the project's name. Scripts that need the name callscript/projectname— e.g.script/dockerassembles its image tag from it — so those scripts stay byte-identical across all repos. Repo-type-specific pre-commit extras (e.g.go mod tidyverification in Go repos) belong inscript/precommit, not in the hook itself. Model scripts are athttps://git.eeqj.de/sneak/prompts/raw/branch/main/script/<name>. 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 typesmake<tab>, 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 runmake checkas a build step so the build fails if the branch is not green — which requiresARG CHECK_EPOCHand its guard in every stage containing a check-runningRUN, per theCHECK_EPOCHrule below. Without them a Dockerfile satisfies this criterion while its check layers are served from cache, so the build cannot fail on a branch that is not green. For non-server repos, the Dockerfile should bring up a development environment and runmake check. For server repos,make checkshould run as an early build stage before the final image is assembled. Dockerfiles install development prerequisites by runningscript/bootstraprather than duplicating installs inline; COPYscript/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 check-running
RUNmust be cache-busted withCHECK_EPOCH. Docker invalidates aCOPYlayer only when the copied content changes, so on an unchanged tree theRUN make checklayer is served from cache, the suite never runs, and the build still exits 0. A sub-seconddocker buildreporting success is a cache hit, not a result. The canonical form, in every stage containing a check-runningRUN:ARG CHECK_EPOCH RUN [ -n "$CHECK_EPOCH" ] || exit 1 RUN echo "check epoch: ${CHECK_EPOCH}" && make checkand in both
script/cibuildandscript/docker:epoch="$(date +%s%N)$$" version="$(git describe --tags --always --dirty 2>/dev/null || true)" [ -n "$version" ] || version="unknown" docker build \ --build-arg CHECK_EPOCH="$epoch" \ --build-arg VERSION="$version" \ .The
VERSIONlines are there for a different reason, covered by the git-describe rule below; they are shown here so the two rules do not each document half a command. All fourCHECK_EPOCHelements are load-bearing; none is optional, and each guards a failure mode that otherwise fails green:ARGis stage-scoped, so a single declaration leaves the other check stages frozen while the fix reviews as complete. Declare it in every stage that runs checks, immediately above the first suchRUN.- Expand the value into the command. This makes the cache miss contractual
rather than dependent on BuildKit's handling of an unreferenced
ARG, and it puts the epoch in the build log. The guard is itself value-keyed, for the same reason: it references$CHECK_EPOCH, so BuildKit renders the epoch into that layer's description (rendered asRUN [ -n "<epoch>" ] || exit 1) and re-runs it whenever the value changes. Each stage therefore has two independent invalidation points, and the guard always precedes the checkRUN. Keep both: the expansion is defence in depth, and it is what makes the epoch visible in the build output. - The
[ -n ... ]guard is required: an unsetARGis empty, and empty is a stable cache key, so without it a baredocker build .still produces the false green. Failed steps are never cached, so the guard fails on every such invocation, loudly. A baredocker build .failing is by design. - Assign
epoch=on its own line, never inline in the--build-argargument: a failing command substitution inside an argument does not tripset -e, so the inline form silently degrades to an empty constant. The$$suffix is required because busyboxdatedrops%Nand exits 0, so on an alpine host the epoch would degrade to second granularity and concurrent invocations would collide.
This invalidates the check layers and everything after them while leaving
go mod download,script/bootstrap, and the pinned toolchain install cached, so it does not push against the five-minute Docker build ceiling. Blanket--no-cachealso works but is wasteful and can blow that ceiling. -
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-lintimage (pinned by hash). This stage runsmake fmt-checkandmake lintbefore the full build begins. The build stage then declares an explicit dependency on the lint stage viaCOPY --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:
# 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 . . ARG CHECK_EPOCH RUN [ -n "$CHECK_EPOCH" ] || exit 1 RUN echo "check epoch: ${CHECK_EPOCH}" && 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 . . ARG CHECK_EPOCH RUN [ -n "$CHECK_EPOCH" ] || exit 1 RUN echo "check epoch: ${CHECK_EPOCH}" && make test # VERSION comes from the host via --build-arg; see the git-describe rule # below. Never run `git describe` here: .dockerignore excludes .git, so # it yields an empty version without failing the build. 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-lintimage 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/nullis 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.- Re-prove that ordering on a warm cache after adopting
CHECK_EPOCH. The cache-bust turns this no-opCOPYinto a content-cache hit, so an ordering guarantee established on a cold cache does not automatically carry over; it has to be re-checked warm. This was re-proved in another repo in the org that uses the same file-dependency trick (there with a marker file in place ofgo.sum), and the ordering held. It has not been verified in this repo, which is single-stage and has no lint stage to order against. Any repo relying on a file-dependency trick for stage ordering should re-check it warm after adopting the bust rather than assuming this result transfers. - If the project uses
//go:embeddirectives 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 withapk add. - The build stage runs
make testafter compilation setup. Tests run in the build stage, not the lint stage, because they may require compiled artifacts or heavier dependencies. ARG CHECK_EPOCHappears in both stages, becauseARGis stage-scoped: declaring it only in the lint stage leavesmake testfrozen at the last cached result. In each stage the guard sits immediately below theARGso a baredocker build .fails instead of reusing the empty cache key, and the value is expanded into the first checkRUNso the cache miss does not rely on BuildKit's unreferenced-ARGhandling. Both of those lines reference$CHECK_EPOCH, so both are value-keyed: each stage is invalidated at two independent points. The laterRUNs in the same stage need no expansion of their own: they are already invalidated by their busted parent layer.ARG VERSION=devis declared in the build stage, and its value is supplied on the host byscript/dockerandscript/cibuildvia--build-arg VERSION=.... Thedevdefault is a placeholder for a local build, not a source of truth. No stage may callgit describe:.dockerignoreexcludes.git, so it yields an empty version without failing. See the git-describe rule further down.
- The lint stage uses the
-
Every repo should have a Gitea Actions workflow (
.gitea/workflows/) that runsscript/cibuild(which runsdocker build --build-arg CHECK_EPOCH="$epoch" --build-arg VERSION="$version" .) on push. The Dockerfile runsmake check, so a successful build implies all checks pass — but that implication holds only because of theCHECK_EPOCHcache-bust described above. Without it, an unchanged tree serves the check layer from cache and the build reports a green it never earned. A baredocker build .fails closed by design, on the[ -n "$CHECK_EPOCH" ]guard; always go throughscript/cibuildorscript/docker. Never accept ascript/cibuildpass as evidence without confirming it ran: a sub-second wall time, orCACHEDon the check layer, means nothing was executed. -
Use platform-standard formatters:
blackfor Python,prettierfor JS/CSS/Markdown/HTML,go fmtfor Go. Always use default configuration with two exceptions: four-space indents (except Go), andproseWrap: alwaysfor Markdown (hard-wrap at 80 columns). Documentation and writing repos (Markdown, HTML, CSS) should also have.prettierrcand.prettierignore. -
Pre-commit hook: runs
script/precommit, which callsscript/check. If local testing is not possible in the repo,script/precommitmay skipscript/testand run onlyscript/lintandscript/fmt-check. The hook is installed byscript/install-precommit; the Makefile must provide amake hookstarget 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 formake testto be a no-op. -
make testmust complete in under 20 seconds. Add a 30-second timeout in the Makefile. -
make testshould use the conditional verbose rerun pattern. Run tests without-v(verbose) first. If tests fail, automatically rerun with-vto show full output. This keeps CI logs anddocker buildoutput clean on success (just package/suite summaries) while providing full diagnostic detail on failure (every test case, every assertion). The general shell pattern:test: @<test-command> || \ { echo "--- Rerunning with -v for details ---"; \ <test-command-with-v>; exit 1; }Go example:
test: @go test -timeout 30s -race -cover ./... || \ { echo "--- Rerunning with -v for details ---"; \ go test -timeout 30s -race -v ./...; exit 1; }Python example:
test: @python -m pytest || \ { echo "--- Rerunning with -v for details ---"; \ python -m pytest -v; exit 1; }The
exit 1ensures 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 checkmust not modify any files in the repo. Tests may use temporary directories. -
mainmust always passmake check, no exceptions. -
Never commit secrets.
.envfiles, credentials, API keys, and private keys must be in.gitignore. No exceptions. -
.gitignoreshould be comprehensive from the start: OS files (.DS_Store), editor files (.swp,*~), in-repo agent scratch directories (.claude/, which holds one worktree — an entire additional checkout of the repo — per in-flight agent), language build artifacts, andnode_modules/. Fetch the standard.gitignorefromhttps://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignorewhen 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.dockerignoreand must not be transplanted into one unmodified — see the next rule. -
.dockerignoredoes not use.gitignoresemantics, and copying patterns across unmodified leaves secrets in the build context. Docker matches withmoby/patternmatcher: Gofilepath.Matchsemantics plus a**extension, compiled to a regexp — plainfilepath.Matchhas no**at all. So*does not cross/, and a pattern without a leading**/is anchored at the build-context root. A.dockerignorelisting.env,*.pemand*.keytherefore excludes only the copies at the repository root;config/.envandcerts/server.keystill reach the context and can land in an image layer. That file is more dangerous than a short one with no secret patterns at all, because it reads as solved and stops anyone looking. Give every depth-independent pattern the**/prefix —**/node_modules,**/.DS_Store, and the secret patterns in the canonical file, which are additionally case-folded per the rule below — and leave only genuinely root-anchored entries unprefixed:.git, the in-repo agent scratch directory.claude, and the repo's own host-built binary. The inverse move is equally wrong: never apply**/to.gitignore, where it is redundant and produces a file that is wrong in a way that looks careful. Each file is written to its own semantics; neither is derived from the other. Fetch the standard.dockerignorefromhttps://git.eeqj.de/sneak/prompts/raw/branch/main/.dockerignoreand extend it with the repo's own host-built artifacts — a hostmake buildthat leaves a compiled binary in the repo root puts that binary in the build context, where.gitignorehides it from every git-based check. Write that binary anchored,/myappand never**/myapp: the prefixed form also matchescmd/myapp/and deletes the package directory from the context. -
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 withCOPY . .the build context inflates by a multiple of the repo, and another session's unreviewed, sometimes uncommitted work can be copied into an image layer. The directory is also created and destroyed constantly, so it invalidatesCOPY . .for reasons that have nothing to do with the repo's own content. In.gitignorethe entry is.claude/, unanchored, which already matches at every depth. In.dockerignoreit is.claude, anchored and with no**/prefix: the directory occurs exactly once where agents run at the repo root, and the prefixed form would also match any nested directory of that name and delete it from the build. It is not case-folded the way the secret patterns are, because tooling creates it in exactly one spelling, so a folded pattern would add no coverage.Known gap that comes with the anchored form. The directory is created in the agent's working directory, so the "exactly once, at the root" premise is a property of how agents are run and not of the tooling. Where agents run in subdirectories — a monorepo with a per-service agent is the ordinary case —
services/api/.claude/is not excluded by the canonical entry and still reaches the build context and the image, which is the exposure the entry exists to close. A repo in that shape adds its own anchored entries (/services/api/.claude), or**/.claudeonce it has confirmed no legitimately named nested directory would be caught. This is stated in the canonical.dockerignoreitself, since that file is what consuming repos receive. -
.dockerignorematching is case-sensitive, so cover capitalisation with character classes rather than by doubling patterns.**/*.keydoes not matchcerts/SERVER.KEY, which is reachable on the case-insensitive filesystems most laptops use. Adding an ALL-CAPS twin for each pattern is not the fix: it still missesServer.KeyandCa.Pemwhile reading as though case were handled — the same manufactured confidence as the root-anchored form. The matcher supports character ranges, so one line covers every spelling:**/*.[kK][eE][yY],**/*.[pP][eE][mM]. Apply this to every secret name, not only to extensions: the extensionless SSH keys and.envrcneed it for the same reason, since on the very filesystems that makeSERVER.KEYreachable, direnv reads.ENVRCand ssh readsID_RSA. Note that*matches the empty string, so**/*.[eE][nN][vV]already covers a bare.ENVand no separate literal.enventry is needed. -
A pattern that also catches something the build needs is re-included with a negation, not deleted. The canonical
**/*.[eE][nN][vV]excludes a committed env template such asexample.env; a repo whose build genuinely reads one adds!docs/example.envafter the pattern. Deleting the pattern instead reopens the exposure for every other file it covers. -
Verify
.dockerignoreby enumerating the image, not by reading the patterns. Plant files at the root and at least two directories deep, build a probe image that doesCOPY . ., and list what actually landed (docker run --rm --entrypoint find IMAGE /app). Reading the patterns and agreeing they look right is exactly what lets the root-only form through. Thetransferring contextsize is not a substitute: a nested secret is a few bytes, and BuildKit transfers only the delta from the previous build, so the reported size describes the transfer and not the contents of the image. -
Excluding
.gitmeansgit describecannot run inside any build stage, and it fails quietly there. TheGOLDFLAGSversion-embedding pattern assumes.gitis present; in a build stage there is no repository, sogit describewrites nothing to stdout and the-X main.Version=value comes out empty rather than erroring. The binary then reports no version at all and the build still exits 0. Compute the version on the host and thread it in as a build arg.script/dockerandscript/cibuilddo this, byte-identically across repos:# Assign on its own line: a failing command substitution inside an # argument does not trip `set -e`, so the inline form degrades to an # empty constant — the same silent-empty failure this rule is about. version="$(git describe --tags --always --dirty 2>/dev/null || true)" [ -n "$version" ] || version="unknown" docker build \ --build-arg CHECK_EPOCH="$epoch" \ --build-arg VERSION="$version" \ .--alwaysmakes an untagged repo yield the abbreviated commit hash instead of failing.|| truekeeps a failinggit describefrom trippingset -eand leaves the value empty, so the[ -n "$version" ]line is the single place the fallback is applied — and it is a live check, not defence in depth: it fires on a build from an export with no.git, and on a repository with no commits yet. Do not fold the fallback into the substitution as|| echo unknown; that makes the guard unreachable, and a guard that cannot fire is indistinguishable from one that works to everyone who copies it. The result is non-empty by construction either way, which is the point: an empty version reads as a successful one, whileunknownis visibly wrong. The Dockerfile's side isARG VERSION=devin the stage that compiles, declared there and not inherited, becauseARGis stage-scoped exactly asCHECK_EPOCHis. PassingVERSIONto a repo whose Dockerfile declares no suchARGis silently ignored by BuildKit and costs nothing, which is why the scripts stay byte-identical rather than growing a per-repo variant.One consequence for CI: the standard checkout action clones shallow and fetches no tags, so
git describe --tagsthere falls back to a bare commit hash. A repo that embeds a tag-derived version must setfetch-depth: 0on its checkout step; a repo that does not embed a version needs no change. -
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 withgo get, which downloads code but does not execute code generation. -
Never use
git add -Aorgit 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.ymlis standardized and must NEVER be modified by an agent, only manually by the user. Fetch fromhttps://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml. The canonical golangci-lint version is v2.12.2 (released 2026-05-06), installed commit-pinned viago install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@c0d3ddc9cf3faa61a4e378e879ece580256d76e5. -
script/bootstrapin Go repos must install the pinned golangci-lint whenever the installed version does not match the pin — not merely when the binary is absent — and must then verify the install took effect by re-resolving the binary throughPATH. The presence testif missing golangci-lint; then go install "$GOLANGCI_LINT_REF"; fiis wrong: it testsPATHpresence and never version, so on any already-provisioned machine the pin is inert and a version bump is a no-op. Meanwhile the Dockerfile installs unconditionally into a clean image, so CI and local silently disagree about what the linter even is. Observed consequences: a localmake checkgreen whilemake dockerrejected the same commit with sixgoconstfindings, and a container linter surfacing thirteen findings the host run missed. A stale host linter does not merely fail to prove the tree is clean — it hides findings only the container can see. This is a deliberate departure from the node handling described above, which uses whatever node is installed: the linter version is the specific thing being held equal between host and container, so for it, presence is not enough.Comparing versions is necessary but not sufficient, because the obvious fix also fails green.
go installwrites toGOBIN(orGOPATH/bin) while callers resolvegolangci-lintthroughPATH. If a different binary shadows it earlier inPATH, the install genuinely succeeds and changes nothing any caller will ever see: bootstrap prints success and the nextmake lintstill runs the stale linter. That is worse than no fix, because it converts a known-stale toolchain into one everyone believes is pinned. The canonical form, placed inscript/bootstrapafter Go itself is present:# golangci-lint v2.12.2, 2026-05-06. GOLANGCI_LINT_VERSION must be exactly # what `golangci-lint --version` prints for this ref; update both together. GOLANGCI_LINT_VERSION="2.12.2" GOLANGCI_LINT_REF="github.com/golangci/golangci-lint/v2/cmd/golangci-lint@c0d3ddc9cf3faa61a4e378e879ece580256d76e5" # The version golangci-lint reports, resolved the way callers resolve it. # Prints nothing when the binary is absent, exits non-zero, or prints # something unparseable: all of those must read as "does not match". # The capture is the whole version token, not just its numeric prefix. # Stopping at the first `-` would make 2.12.2-rc1 compare equal to 2.12.2 # and skip the install, which is the defect this whole rule exists to close. # The trailing `|| true` is required, not tidiness. Under `set -o pipefail` # a non-zero --version would otherwise propagate out of the pipeline and # kill the script through `set -e` before the diagnostic below is printed. golangci_lint_version() { command -v golangci-lint >/dev/null 2>&1 || return 0 golangci-lint --version 2>/dev/null | head -n 1 | sed -n 's/.*has version v\{0,1\}\([0-9][^ ]*\).*/\1/p' || true } ensure_golangci_lint() { if [ "$(golangci_lint_version)" = "$GOLANGCI_LINT_VERSION" ]; then echo "bootstrap: golangci-lint $GOLANGCI_LINT_VERSION already installed" return 0 fi echo "bootstrap: installing golangci-lint $GOLANGCI_LINT_VERSION" go install "$GOLANGCI_LINT_REF" # go install writes to GOBIN (or GOPATH/bin); callers resolve through # PATH. Re-resolve through PATH and assert the install took effect. # `hash -r` is load-bearing: without it a shell that already resolved # a stale golangci-lint answers from its own lookup cache, and this # check false-fails with the shadowing message below. hash -r 2>/dev/null || true gcl_got="$(golangci_lint_version)" if [ "$gcl_got" = "$GOLANGCI_LINT_VERSION" ]; then echo "bootstrap: golangci-lint $GOLANGCI_LINT_VERSION installed," \ "and PATH resolves it" return 0 fi gcl_bin="$(go env GOBIN)" [ -n "$gcl_bin" ] || gcl_bin="$(go env GOPATH)/bin" # Strip a trailing slash: GOBIN=/x/ would otherwise make the # "$gcl_bin"/* test below miss and misreport shadowing. while :; do case "$gcl_bin" in */) gcl_bin="${gcl_bin%/}" ;; *) break ;; esac done gcl_found="$(command -v golangci-lint 2>/dev/null || true)" echo "bootstrap: installed golangci-lint $GOLANGCI_LINT_VERSION into" \ "$gcl_bin, but that is not what callers will get." >&2 case "$gcl_found" in "") echo "bootstrap: PATH resolves no golangci-lint at all." \ "Add $gcl_bin to PATH, then re-run bootstrap." >&2 ;; "$gcl_bin"/*) echo "bootstrap: PATH resolves $gcl_found, inside that same" \ "directory, reporting version ${gcl_got:-unparseable}." \ "Nothing is shadowing it, so the install itself did not" \ "produce the pinned version: check that" \ "GOLANGCI_LINT_VERSION matches GOLANGCI_LINT_REF." >&2 ;; *) echo "bootstrap: PATH resolves $gcl_found instead, reporting" \ "version ${gcl_got:-unparseable}. Remove that binary or" \ "put $gcl_bin earlier in PATH, then re-run bootstrap." >&2 ;; esac exit 1 } # The definitions above are inert on their own; the call site is part of # the canonical form. In a script/bootstrap that follows the "define all # functions, then call main" convention, this line belongs inside main() # next to the other ensure_* steps. ensure_golangci_lintFour properties are load-bearing; each guards a failure mode that otherwise fails green:
- Compare the installed version against the pin, never test presence.
This is what makes a version bump propagate to machines that already have
some golangci-lint. Compare the whole version token, exactly: a parser
that stops at the first
-reports2.12.2for a host running2.12.2-rc1, which compares equal to a2.12.2pin and skips the install — the original defect, reintroduced through the comparison meant to fix it. - After installing, re-resolve the binary the way callers resolve it —
through
PATH, not the pathgo installwrote to — and assert--versionreports the pin. When it does not, fail non-zero and name the pathcommand -vactually found, the version it reports, and the directory the install wrote to. That is a condition a human has to fix by hand, so bootstrap must not print success in it. Usehash -rfirst so the shell does not answer from its own lookup cache. Diagnose the cause from the resolved path rather than asserting one: only a path outside the install directory is shadowing. When the resolved path is inside it, nothing is shadowing and telling the operator to delete that binary or reorderPATHsends them after a fault that does not exist. - A mis-parse must fall through to reinstall, never to a false match. Absent binary, non-zero exit, empty output, and unrecognised output all yield an empty string, which compares unequal to the pin. The failure direction is always a redundant install, never a skipped one.
- Call it, and say so on success. Two function definitions with no call site are a silent no-op that reproduces the original defect exactly: exit 0, nothing installed, no output, stale linter still resolved. A success path that prints nothing is byte-identical to that no-op — same exit status, same empty output — so both success branches must print a confirmation naming the version. In a change about undetectable no-ops, "it printed nothing and exited 0" must not be the healthy signal.
Keep it POSIX sh: no bashisms, no arrays, no
[[, nogrep -P.On the hash-pinning rule.
@c0d3ddc9cf3faa61a4e378e879ece580256d76e5is a commit hash, not a server-mutable version tag, and the go command verifies the fetched module against the checksum database — the mechanism the hash-pinning rule at the top of this document already names as acceptable for Go modules. Note thatgo install pkg@versionruns in module-aware mode ignoring thego.modin the current directory or any parent, so no repogo.sumis consulted for this install; the checksum database is what verifies it. The linter is a bootstrap prerequisite rather than part of any repo's module graph, which is why the canonical form installs it by commit-pinned ref instead of declaring it ingo.mod. Whether ago.modtool dependency — which would pin the hash in a committed, reviewable file instead — should replace this is an open decision, tracked at prompts#37.Keep
GOLANGCI_LINT_VERSIONand the ref in sync. The ref is a hash and carries no readable version, so the expected version is a separate string, and it must be exactly what--versionprints for that ref — the comparison is an exact match on the whole version token. When the pinned commit carries a release tag the go command resolves the hash to that tag, so the string is simply the release number,2.12.2here. When it does not, the go command falls back to a pseudo-version and the binary reports something like2.12.3-0.20260506110758-c0d3ddc9cf3f; that compares exactly like any other string, so it works, but it cannot be known without building the binary once and reading--versionoff it. Prefer pins on tagged releases for that reason — the expected string is then derivable from the ref — not because the comparison cannot handle the alternative.Because the comparison covers the whole token, a pre-release is never confused with its release: a host carrying
2.12.2-rc1against a2.12.2pin compares unequal and gets reinstalled. This matters more than it looks, because a pre-release tag is still a tag, so a rule requiring merely that the pin be tagged would not catch it.Verifying a change to this logic requires a negative control run in an environment where a shadowing binary exists earlier in
PATHthan the install target. Without that, the control passes against the naive compare-then-install form as well and therefore proves nothing. Also check the mis-parse direction by feeding it unparseable--versionoutput and confirming it reinstalls rather than reporting a match.Run those controls against the block as a consuming repo would adopt it — pasted into a
script/bootstrap-shaped file that is then executed — not by sourcing it and invoking the function yourself. Driving the function directly tests something the artifact does not do, and it is exactly how a missing call site passes every control while the adopted snippet does nothing. - Compare the installed version against the pin, never test presence.
This is what makes a version bump propagate to machines that already have
some golangci-lint. Compare the whole version token, exactly: a parser
that stops at the first
-
script/lintin Go repos must give golangci-lint per-checkout cache and lock state, and must never report a lock collision as a lint result. golangci-lint shares two pieces of state across every process on the host, and they are separate mechanisms with separate fixes. Isolating one and stopping leaves the other fully live while reading as a fix. This is independent of the pinned-install rule above and does not replace it: that one makes the host run the right linter, this one makes the run's result belong to your own tree.Mechanism 1, the result cache — produces false greens as well as false reds. golangci-lint keys cached results on file content, not location, so two checkouts of the same commit hold byte-identical files, share cache entries, and one tree's findings are served for the other — reported at the other tree's path. Observed across the org: 399 issues attributed to a
/tmpworktree that no longer existed, returned from a clean clone that genuinely lints 0 issues; ten findings against a deleted worktree; findings reported against../wt82-lint/...; and, in the dangerous direction, an implementer reporting "lint 0 issues" on a branch that was genuinely red. Note what content-keying implies: moving agents from worktrees to their own clones does not help. Two clones of a repo are byte-identical exactly as two worktrees were. What own-clones removes is the deleted-worktree path artefact — the loud, obviously wrong symptom — while leaving the mechanism live, which makes the defect quieter rather than rarer.Mechanism 2, the concurrency lock — and it does not live in the cache directory. From
pkg/commands/run.go,acquireFileLock():lockFile := filepath.Join(os.TempDir(), "golangci-lint.lock")That is
$TMPDIR/golangci-lint.lock— host-global, keyed on the temp directory, entirely independent ofGOLANGCI_LINT_CACHE. It is anflockretried every second under a 5-second total timeout, so it fails precisely when the host is busiest. On failure the run emitsparallel golangci-lint is runningand analyzes nothing. A private cache directory does not prevent this; that was established by controlled test, with two concurrent runs under separate cache directories sharing no mounted path, one of which still collided. Anyone who sets onlyGOLANGCI_LINT_CACHEhas closed the contamination half and left the false-red half untouched.The canonical form for a Go repo's
script/lint:#!/bin/sh # script/lint: run the linter. set -eu ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" # Per-checkout golangci-lint state. Both variables are required and they # fix different defects; neither is redundant with the other. # # GOLANGCI_LINT_CACHE: the result cache is keyed on file content, not # location, so checkouts holding identical files serve each other's # findings. A per-REPO cache directory does NOT fix this — every checkout # of that repo still collides — so the path must be inside the invoking # checkout. # # TMPDIR: golangci-lint flocks $TMPDIR/golangci-lint.lock # (pkg/commands/run.go, acquireFileLock: filepath.Join(os.TempDir(), # "golangci-lint.lock")). That lock is host-global and independent of # GOLANGCI_LINT_CACHE, with a 5s acquire timeout. Scoping TMPDIR into the # checkout is the only thing here that isolates it. Do not delete this as # redundant with the cache variable; it is not. # # The leading dot in .lint-cache is load-bearing: the go tool skips # dot-prefixed directories when expanding ./..., so the linter never reads # its own cache and temp files back as source. Do not rename it. LINT_STATE="$ROOT/.lint-cache" GOLANGCI_LINT_CACHE="$LINT_STATE/cache" TMPDIR="$LINT_STATE/tmp" export GOLANGCI_LINT_CACHE TMPDIR mkdir -p "$GOLANGCI_LINT_CACHE" "$TMPDIR" # Capture files, per INVOCATION and not per checkout. Two runs in the same # checkout would otherwise redirect into one pair of fixed paths, opened # O_TRUNC before the linter even starts, and each would print and scan the # other's output — a run reporting a result that is not its own, which is # the whole defect this bullet exists to close, one layer up from where it # was closed. That case is not hypothetical here: two runs in the same # checkout is exactly what --allow-serial-runners below exists to support, # and serialising the linter does not serialise the shell's redirections # or the grep and cat that read them. mktemp rather than $$: two # containerised runs over one bind-mounted checkout are in separate PID # namespaces and can both be PID 7, which puts the collision back. LINT_OUT="$(mktemp "$LINT_STATE/run.out.XXXXXX")" LINT_ERR="$(mktemp "$LINT_STATE/run.err.XXXXXX")" # Print whatever the linter had written before the interruption, then exit # 128+signal. The exit is what makes this handler TERMINATING, and that is # the point: a signal-trap handler that returns RESUMES the script. With # the capture files already deleted, execution would fall into the grep # below against a missing file, take the not-a-collision branch, and report # the FINDINGS exit status with empty output for a run that was killed — # after deleting the findings it was about to print. That is why cleanup # is on EXIT only. Killing a run is not hypothetical here: it is the stated # mitigation for the unbounded wait --allow-serial-runners can produce, and # it is what Ctrl-C on a make check does. # # All three writes are best-effort, and the `|| :` on each is load-bearing # rather than defensive habit. Under set -e a failed write aborts the # function BEFORE exit "$1", and the shell then exits 1 — the findings # status, on a run that analysed nothing. SIGHUP is precisely the case # where writing fails: once the controlling terminal is gone the writes # return EIO. Guarding only the two cats is not enough, because with the # capture files empty the cats write nothing and succeed, and the echo is # what fails. lint_interrupted() { if [ -f "$LINT_ERR" ]; then cat "$LINT_ERR" >&2 || :; fi if [ -f "$LINT_OUT" ]; then cat "$LINT_OUT" || :; fi echo "lint: interrupted by a signal, so nothing was completed." \ "This is NOT a lint result." >&2 || : exit "$1" } # The EXIT trap still fires on the way out of a terminating handler, so # cleanup happens exactly once on every path. `|| :` because a failing rm # — an unwritable state directory is enough — would otherwise change the # exit status of an otherwise clean run under set -e. trap 'rm -f "$LINT_OUT" "$LINT_ERR" || :' EXIT trap 'lint_interrupted 129' HUP trap 'lint_interrupted 130' INT trap 'lint_interrupted 143' TERM # Backstop for a caller that reached the linter without the environment # above. With it set, this should never fire. LINT_MAX_ATTEMPTS=5 # EX_TEMPFAIL. Distinct from 1 (findings) and 3 (linter error) so a void # run is never counted as either. LINT_VOID_EXIT=75 golangci_lint_run() { attempt=1 delay=2 while :; do rc=0 # --allow-serial-runners KEEPS the mutual-exclusion guard and makes # an overlapping run queue on the lock instead of aborting after # 5s. It is NOT --allow-parallel-runners, which removes the guard # entirely; never use that one. This is what covers two runs inside # the SAME checkout, which TMPDIR scoping cannot — script/precommit # overlapping a make check is the realistic trigger. golangci-lint run --allow-serial-runners "$@" \ >"$LINT_OUT" 2>"$LINT_ERR" || rc=$? # Detect the lock collision on the STDERR STREAM, never on the exit # status. Findings are written to stdout and golangci-lint reports # this failure only on stderr, so a finding that quotes the string # from source cannot be mistaken for a collision and retried away — # that direction would be a false green. The exit status is not a # usable discriminator: the collision exits 3 (exitcodes.Failure), # which does separate it from findings at 1, but run.go returns it # as a plain error that Execute maps to Failure like every other # error at that level, so 3 cannot separate a collision from a # genuine linter failure — an unknown linter name, an unknown flag, # malformed config YAML all exit 3 too. (An unparseable Go source # file does not: that is reported as typecheck issues and exits 1.) # Retrying on 3 would retry real failures into a void. if ! grep -q 'parallel golangci-lint is running' "$LINT_ERR"; then cat "$LINT_ERR" >&2 cat "$LINT_OUT" return "$rc" fi if [ "$attempt" -ge "$LINT_MAX_ATTEMPTS" ]; then cat "$LINT_ERR" >&2 echo "lint: VOID after $LINT_MAX_ATTEMPTS attempts:" \ "golangci-lint never acquired its lock, so nothing was" \ "analyzed. This is NOT a lint result and no verdict may" \ "be recorded from it. Re-run it." >&2 return "$LINT_VOID_EXIT" fi echo "lint: lock held by another golangci-lint; attempt" \ "$attempt of $LINT_MAX_ATTEMPTS, retrying in ${delay}s" >&2 sleep "$delay" attempt=$((attempt + 1)) delay=$((delay * 2)) done } main() { cd "$ROOT" golangci_lint_run ./... } main "$@"Load-bearing properties, each guarding a mode that otherwise reports a verdict it did not earn:
- Both variables, per checkout. Cache alone leaves the false reds;
TMPDIRalone leaves the contamination that produced a confirmed false green. A per-repo path for either is not isolation on a host where every worker holds its own copy of the same repo. - Set them on every path that reaches the linter. The export block goes
above any container-versus-host branch, and a native escape hatch must
call
golangci_lint_runrather thanexec golangci-lintdirectly. One repo in the org had exactly one such path with no cache environment at all, inheriting the fleet-wide default, so the fix on the other path was worth nothing there. - The lock collision is not a result, and must never be reported as one. Retry it, and on exhaustion exit a status that is neither the findings status nor success, with a message that says VOID. Swallowing it into a success is the worst available outcome; reporting it as findings sends a correct branch back for rework against findings that do not exist.
- Detect the collision by the message on stderr, not by exit status, and never retry a genuine finding. Distinguishing "exited non-zero because of the lock" from "exited non-zero because of findings" is the whole crux, and getting it wrong in the direction of treating findings as a lock error retries a real failure into a void — or, if a later implementation decided to treat exhaustion as success, into a green.
--allow-serial-runners, never--allow-parallel-runners. The first keeps the guard and queues; the second deletes it and lets two runs corrupt shared state. With the flag set, an overlapping run inside the same checkout waits rather than failing, which is what is actually wanted. The honest cost is that it waits without bound, so a stale process holding the lock hangs the run instead of failing it; the contending set is bounded to the same checkout, and an eventual result is preferable to a fabricated one.- Capture stdout and stderr to per-INVOCATION paths, and clean them up.
Two fixed paths under the checkout are one pair for every run in it, and
the redirections truncate them before the linter starts, so two
overlapping runs print and scan each other's output.
--allow-serial-runnersdoes not prevent this — it serialises the linter, not the shell — and the overlap it exists to support is preciselyscript/precommitagainst amake checkin one checkout. The observed shapes are a run printing the other's0 issues.while its own linter found something, and a lock error erased beforegrepreads it, so the retry never fires and the void run returns as a result. Both are a run reporting a result that is not its own, which is this bullet's entire subject reintroduced one layer above where it was fixed. Usemktempunder the state directory rather than$$: two containerised runs over one bind-mounted checkout sit in separate PID namespaces and can hold the same low PID, which puts the collision back on exactly the fleet's arrangement.$$is acceptable only wheremktempis unavailable and that arrangement is ruled out. - Clean up on
EXITonly, and give each signal a TERMINATING handler. A signal-trap handler that does not exit resumes the script: with the capture files already deleted, the run falls into thegrepagainst a missing file, takes the not-a-collision branch, and reports the findings exit status with empty output for a run that was killed — having deleted the findings it was about to print. Measured:TERM,INTandHUPall returning 1 with empty stdout, against 143, 130 and 129 for the same block without the handler. Exit128+signalinstead, and let theEXITtrap do the cleanup on the way out. The handler also makes a best-effort attempt to print what the linter had already written — best-effort because underSIGHUPthe terminal is typically gone and every write returnsEIO, in which case nothing is printed and only the status carries the message. Each of those writes needs its own|| :: underset -ea failed write aborts the handler before it reachesexit, and the shell then exits 1, which is the findings status on a run that analysed nothing. Guarding only thecats is not enough — with the capture files empty they write nothing and succeed, and theechois what fails. Put|| :on thermin theEXITtrap for the same reason: an unwritable state directory makes it fail, and a failingEXITtrap underset -eturns an otherwise clean run into exit 1, measured in bothdashandbash. - A signal must reach the LINTER, not just the wrapper. POSIX defers a
trap until the running foreground command completes, so
kill -TERM <wrapper pid>does nothing at all whilegolangci-lintis running — measured still alive three seconds later, where the same block without a handler dies immediately at 143. Ctrl-C is unaffected because the terminal signals the whole process group. This matters exactly where the handler is supposed to help: the unbounded--allow-serial-runnerswait, where the process holding things up is the linter itself. Kill the group (kill -- -<pgid>) or use Ctrl-C. - Known, accepted gap: a signal arriving between the
mktempcalls and thetrap ... EXITline leaves the two capture files behind. Closing it needs a trap installed before the files have names and rewritten after, which is more moving parts than a couple of stray files in a gitignored directory is worth. Stated rather than silently left.
Adopting repos must add
.lint-cache/to both.gitignoreand.dockerignore. The second matters as much as the first: the directory reaches tens of megabytes, and without the exclusion it enters the build context and invalidatesCOPY . .for reasons unrelated to the repo's content.GOCACHEdoes not need isolating, and this was measured rather than assumed. WithGOLANGCI_LINT_CACHEandTMPDIRper checkout andGOCACHEleft at the host default and shared, two checkouts of identical content each reported their own paths and neither reported the other's. The Go build cache is content-addressed and its entries are compiled artifacts rather than diagnostics carrying a foreign tree's paths, and it has no equivalent global lock — the whole fleet compiles concurrently against oneGOCACHEall day without a contention error. Isolating it would cost a full cold compile per checkout for no measured benefit. The earlier hypothesis that Go build-cache contention might explain the lock error is superseded: the lock is located in source at$TMPDIR/golangci-lint.lock.Verifying a change to this logic requires a negative control, and the control must be built out of checkouts with identical content. Create two checkouts of the same tree containing a deliberate lint finding, run the linter in the second so it populates the cache, then run it in the first and confirm the finding is reported at the first checkout's own path and never at the second's. Run the same control against the unisolated form and confirm the contamination appears there — a control that passes against the broken implementation proves nothing. Note specifically that a control built from checkouts whose content differs passes against the unisolated form too, because differing content does not collide in a content-keyed cache, so it is not a test of anything. For the lock half, hold
$TMPDIR/golangci-lint.lockwithflockand confirm the run queues rather than aborting, that a caller never sees the collision as findings, and that exhaustion fails loudly and distinguishably.Run those controls against the block as a consuming repo would adopt it — pasted into a
script/lint-shaped file that is then executed, not sourced with the functions driven by hand. The same warning as for the bootstrap block above, for the same reason. - Both variables, per checkout. Cache alone leaves the false reds;
-
Interim rule for reading a golangci-lint result on a shared host, until every repo has adopted the isolation above. A lint run is VOID unless both hold:
- the output contains no
parallel golangci-lint is running, and - no reported file path begins with
../, and none is an absolute path outside the tree the run was launched from.
Do not record a verdict from a void run, and do not "fix" findings in files the change does not touch — chasing phantom findings across untouched files puts unrelated edits into a reviewed diff, which is more expensive than the wasted rework.
The
../clause is the one that actually bites, and it is why a filter keyed on/tmpor on absolute prefixes is not enough: golangci-lint reports paths relative to its own resolved root rather than yours, and three of the org's reported sightings had relative paths and would have passed such a filter. Both clauses are needed and neither alone is sufficient — one reproduction exited non-zero with the lock error and no foreign paths at all, and another reported 34 well-formed findings, every one of them against another checkout.State the limit of these tests rather than treating them as a guarantee. They catch contamination that names foreign files. They cannot catch contamination that suppresses findings through a poisoned entry for colliding content, which has no wall-clock tell either — no evidence of that mode has been observed, and nobody should go chasing it; the point is the reach of the tests, not a claim that the mode exists. They are a filter for the loud mode, not a proof of soundness — which is the whole argument for fixing this in the tooling instead of documenting a discipline that depends on every agent remembering to apply it.
- the output contains no
-
When pinning images or packages by hash, add a comment above the reference with the version and date (YYYY-MM-DD).
-
Use
yarn, notnpm. -
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) withmax-ageof at least one year andincludeSubDomains.Content-Security-Policy(CSP) with a restrictive default policy (default-src 'self'as a baseline, tightened per-resource as needed). Never useunsafe-inlineorunsafe-evalunless unavoidable, and document the reason.X-Frame-Options: DENY(orSAMEORIGINif framing is required). Prefer theframe-ancestorsCSP directive as the primary control.X-Content-Type-Options: nosniff.Referrer-Policy: strict-origin-when-cross-origin(or stricter).Permissions-Policyrestricting 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).
ReadTimeoutandReadHeaderTimeouton thehttp.Serverto defend against slowloris attacks.WriteTimeouton thehttp.Server.IdleTimeouton thehttp.Server.- Per-handler execution time limits via
context.WithTimeoutor chi/stdlibmiddleware.Timeout.
- Maximum request body size enforced on all endpoints (e.g. Go
- 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
Authorizationheader (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, andSameSite=Lax(orStrict) 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 trustX-Forwarded-Forunconditionally.
- True client IP detection when behind a reverse proxy
(
- CORS:
- Authenticated endpoints must restrict
Access-Control-Allow-Originto an explicit allowlist of known origins. Wildcard (*) is acceptable only for public, unauthenticated read-only APIs.
- Authenticated endpoints must restrict
- 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
DEBUGis enabled.
- 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
- 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
Securecookie flags must still be set by the application so that the browser enforces HTTPS end-to-end.
- 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
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.
- Security headers on every response:
-
README.mdis 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
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
LICENSEfile in the repo root and a License section in the README. - Author: @sneak.
-
First commit of a new repo should contain only
README.md. -
Go module root:
sneak.berlin/go/<name>. Always rungo mod tidybefore 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.sqldirectly. - Post-1.0.0: add new numbered migration files for each schema change. Never edit existing migrations after release.
-
All repos should have an
.editorconfigenforcing 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 toolscmd/— Go command entrypointsconfigs/— configuration templates and examplesdeploy/— deployment manifests (k8s, compose, terraform)docs/— documentation and markdown (README.md stays in root)internal/— Go internal packagesinternal/db/migrations/— database migrationspkg/— Go library packagesshare/— systemd units, data filesstatic/— static assets (images, fonts, etc.)web/— web frontend source
-
When setting up a new repo, files from the
promptsrepo may be used as templates. Fetch them fromhttps://git.eeqj.de/sneak/prompts/raw/branch/main/<path>. -
New repos must contain at minimum:
README.md,.git,.gitignore,.editorconfigLICENSE,REPO_POLICIES.md(copy from thepromptsrepo)Makefilescript/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