check / check (push) Successful in 55s
Long-lived integration branch. One commit per work unit lands here; this PR accumulates them until it is merged to `main`. ## Landed units - **Run all linting in Docker via `Dockerfile.lint` + `script/lint`** — #134 golangci-lint is no longer installed or run on the host. New root `Dockerfile.lint` COPYs the repo into the digest-pinned `golangci/golangci-lint:v2.12.2` image and lints as a build step, so a successful build IS a clean lint; `script/lint` is reduced to a thin wrapper that builds it. This also works where the docker daemon is remote and bind mounts are impossible. **Pinned digest and how it was verified.** `golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240`, exactly as quoted in the issue. It resolves, and it is genuinely v2.12.2: ``` $ docker buildx imagetools inspect golangci/golangci-lint:v2.12.2 Name: docker.io/golangci/golangci-lint:v2.12.2 MediaType: application/vnd.oci.image.index.v1+json Digest: sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 $ docker run --rm golangci/golangci-lint@sha256:5cceeef04e...ad5240 golangci-lint --version golangci-lint has version 2.12.2 built with go1.26.2 from c0d3ddc9 on 2026-05-06T11:07:58Z ``` The tag's index digest is the quoted digest, and the binary inside reports commit `c0d3ddc9`, matching the org's canonical pin `c0d3ddc9cf3faa61a4e378e879ece580256d76e5`. **Forcing the linter to actually run.** `Dockerfile.lint` is split into a `deps` stage (base image + `go mod download`) and a `lint` stage (source copy + linter run). `script/lint` runs: ``` docker build --progress=plain --no-cache-filter=lint --target lint -f Dockerfile.lint . ``` Caching is explicitly waived for linting, and a cached build lints nothing, so the `lint` stage is invalidated on every invocation. The invalidation is scoped: the `deps` stage stays cached and no global cache wipe is performed. `--progress=plain` keeps the linter's own output visible. **`golangci-lint config verify`: deliberately NOT included.** It fetches its JSON schema over a live, unpinned HTTPS call, which would make linting network-dependent and defeat hash-pinning. Omitted for that reason, and the reason is recorded in a comment at the top of `Dockerfile.lint`. **`script/bootstrap`** no longer installs golangci-lint (and its pinned ref is gone); it warns non-fatally when `docker` is absent instead. The `goimports` install stays, because `script/fmt` and `script/fmt-check` still run on the host. Header comment updated accordingly. **Root `Dockerfile`** — required consequence, not scope creep. Its builder stage ran `make check`, which now calls `script/lint`, which shells out to `docker build`; there is no docker daemon inside a docker build, so `script/cibuild` and `script/docker` would have broken. It gains its own lint stage on the same pinned image (linter invoked directly, with a comment explaining why not `make lint`), with the builder stage depending on it via `COPY --from=lint /src/go.sum /dev/null` and running `make fmt-check`, `make test`, `make build`. The now-unneeded golangci-lint install is gone from the builder stage. **README** `Entrypoints` and `Building` sections now describe linting as a docker-only operation. `TODO.md` updated in the same commit. ### Verification All runs via `make` / `script/` entrypoints only. Two consecutive `make lint` runs on an unchanged tree, both executing the linter: ``` # run 1 #10 [lint 2/2] RUN golangci-lint run --config .golangci.yml ./... #10 10.98 0 issues. #10 DONE 12.0s # run 2, tree untouched #10 [lint 2/2] RUN golangci-lint run --config .golangci.yml ./... #10 14.63 0 issues. ``` A third run shows the cache scoping is working as intended — `deps` served from cache, `lint` re-executed: ``` #6 [deps 2/4] WORKDIR /src #6 CACHED #7 [deps 3/4] COPY go.mod go.sum ./ #7 CACHED #8 [deps 4/4] RUN go mod download #8 CACHED #9 [lint 1/2] COPY . . #10 [lint 2/2] RUN golangci-lint run --config .golangci.yml ./... ``` **Negative control.** A deliberate violation (an unused function containing an ineffectual assignment) was added to `internal/config/config.go`: ``` #10 11.26 internal/config/config.go:29:2: ineffectual assignment to x (ineffassign) #10 11.26 internal/config/config.go:28:6: func negativeControlUnused is unused (unused) #10 11.26 2 issues: #10 ERROR: process "/bin/sh -c golangci-lint run --config .golangci.yml ./..." did not complete successfully: exit code: 1 ERROR: failed to build: failed to solve: process "/bin/sh -c golangci-lint run --config .golangci.yml ./..." did not complete successfully: exit code: 1 make: *** [Makefile:26: lint] Error 1 ``` `make lint` exited non-zero naming both findings and their exact lines. After reverting the file, `make lint` was clean again (`0 issues.`). **`make check`** green end to end (test, lint, fmt-check), exit 0. **`script/cibuild`** green, confirming the `Dockerfile` restructure does not recurse: the lint stage ran (`#16 12.56 0 issues.`), then `#22 [builder 8/9] RUN make test` with `PASS` lines, then `#23 [builder 9/9] RUN make build`. ### Notes for the owner This supersedes two PRs you still have queued for merge, both of which tune host linting that no longer exists after this change: #128 (isolates the host golangci-lint cache and lock) and #131 (always installs the pinned lint tools in `script/bootstrap`). Neither was merged or incorporated here. Made moot by this change: #121 and #130. ### Review outcome Reviewed at #136 (comment) — **PASS**. Three non-blocking comment-accuracy findings were recorded there for the next touch of those files; the reviewer independently confirmed the lint gate is live by negative control from a warm cache. - **Live DNS tests made robust instead of gated; test caps moved to the org-wide 60s/20s/90s values** — #93 The resolver's live-DNS tests failed nondeterministically, a different subset each run. Fixed by engineering the nondeterminism out, not by routing around the network. **Nothing is mocked, faked, stubbed, recorded or replayed; there is no `-short` flag, no build tag, no skip, and no environment-tolerance for restricted egress.** Production resolver behaviour is unchanged. ### Root causes, all test-side 1. **Burst fan-out at one root server.** Every test in `internal/resolver` calls `t.Parallel()` and the build hosts have many cores (48 here), so all ~35 iterative resolutions started within milliseconds of each other, and because `queryServers` walks `rootServerList()` in fixed order they all aimed their first query at `198.41.0.4`. Root servers rate-limit that, which fits the reported symptom of a different arbitrary subset failing each run. 2. **No retry anywhere.** One dropped UDP packet in a delegation chain failed a test outright. 3. **Unanimity assertions.** `TestQueryAllNameservers_AllReturnOK` and `_NXDomainFromAllNS` required *every* nameserver of a domain to answer — four independent chances to fail per run, with no tolerance for one being slow. ### What was built New `internal/resolver/livedns_test.go` holds all the live-DNS machinery, so `resolver_test.go` itself takes only call-site edits: - **Bounded live concurrency.** A package-wide semaphore (`liveConcurrency = 6`) caps how many live resolutions are in flight at once. Tests keep `t.Parallel()`; only their network work is throttled. This is the direct fix for cause 1, and the 60s budget is what makes it affordable. - **Retry with exponential backoff.** Three attempts per live operation, 8s deadline each, 500ms base backoff doubling. The retry predicate is deliberately **transport-level** — "did a nameserver answer at all" — and never the assertion the test is making, so a resolver that answers *incorrectly* still fails on the first attempt rather than being retried into a false green. - **Quorum instead of unanimity, tolerating SILENCE ONLY.** A strict majority of the discovered nameservers must answer as expected, and every individual result must additionally fall inside a closed **allowlist** of statuses the test explicitly sanctions: `ok`/`timeout`/`error` for the all-OK test, `nxdomain`/`timeout`/`error` for the NXDOMAIN test. A nameserver that stays silent is tolerated; one that answers **wrongly** is not, at any count. The allowlist is the load-bearing part — see the rework note below for why a blocklist was not enough. New `internal/resolver/livedns_harness_test.go` tests that machinery directly — quorum arithmetic, status counting, the allowlist, the gate's concurrency bound, per-attempt deadlines, and recovery from a transient failure. It performs no DNS resolution of any kind, so it neither mocks DNS nor depends on it. ### Rework after review — the quorum could not fail on a wrong answer The review at #136 (comment) returned **FAIL** on `9cb2c2b`, correctly. Fixed in `87bce43`. **The defect.** The claim above was, as first written, false for `resolver.StatusNoData`. Each test banned exactly one wrong status — `_AllReturnOK` banned only `nxdomain`, `_NXDomainFromAllNS` banned only `ok` — and `nodata` is neither. It is a **wrong answer, not silence**: `answeredCount` counted it as answered, so it did not even trigger a retry, and with a quorum of 3-of-4 a single wrong nameserver slid through undetected. The pre-change unanimity assertions would have caught it. That is robustness work quietly becoming assertion-loosening, which is exactly what this repo cannot afford. **The fix.** Tolerance is now an allowlist, not a blocklist of one status. New `unsanctionedStatuses()` returns every per-nameserver result whose status the caller did not explicitly sanction, and each test asserts that list is empty in addition to its quorum. A blocklist bans the one wrong answer its author thought of and silently admits everything else, including any status added to the resolver later; an allowlist fails on anything nobody sanctioned. `answeredCount` was reframed the same way — it now counts the closed set `ok`/`nxdomain`/`nodata`, so an unfamiliar status is treated as silence and can only ever cause a retry and then a loud failure, never a quiet pass. **Evidence — the reviewer's exact probe, re-run.** `queryEachNS` in `internal/resolver/iterative.go` was patched to force one of `google.com`'s four nameservers to return `StatusNoData` with empty records. `make test` now goes **red**, naming the offending nameserver and status: ``` exit=2 --- FAIL: TestQueryAllNameservers_AllReturnOK (1.12s) Error: Should be empty, but was [ns1.google.com.=nodata] Messages: every nameserver must answer OK or not answer at all: ns1.google.com.=nodata ns2.google.com.=ok ns3.google.com.=ok ns4.google.com.=ok --- FAIL: TestQueryAllNameservers_NXDomainFromAllNS (1.34s) Error: Should be empty, but was [ns1.google.com.=nodata] Messages: every nameserver must report NXDOMAIN or not answer at all: ns1.google.com.=nodata ns2.google.com.=nxdomain ns3.google.com.=nxdomain ns4.google.com.=nxdomain FAIL sneak.berlin/go/dnswatcher/internal/resolver 1.704s ``` That is the same input that returned `exit=0` with both tests **passing** under the old assertions. Probe reverted, tree clean, suite green again: ``` exit=0 ok sneak.berlin/go/dnswatcher/internal/resolver 4.185s coverage: 77.1% of statements (zero `(cached)` lines) ``` Two harness tests lock the regression in without any probe: three OK plus one `nodata` (quorum satisfied, no NXDOMAIN present — the exact input that used to pass) is reported as unsanctioned, and an unknown status is neither counted as answered nor tolerated. Also fixed from the review: the per-attempt deadline assertion in `livedns_harness_test.go` had no lower bound, so it passed for a deadline far shorter than intended. It now asserts the remaining time exceeds `liveAttemptTimeout/2` as well. Deliberately **not** done in this rework, per the review and the owner: no `-count=1` in `script/test` (the test-cache issue is real but pre-existing and repo-wide, filed separately); the remaining non-DNS mocks stay for #97; `queryServers` root-ordering stays untouched under #138. ### The `-timeout` backstop value: 90s Per the ruling at sneak/prompts#41 (comment) the cap is org-wide with two tiers: **60s hard cap for CI green, 20s target, and anything between the two must be filed as an improvement bug.** The backstop is **`90s`**, matching sneak/prompts#42 and preserving the 1.5x backstop-to-cap ratio the old 20s/30s pair had. It must strictly exceed the 60s cap or the cap is unreachable — the old `-timeout 30s` would have killed a 60s-capped suite at half its allowance. Applied to `script/test`; nothing else in the repo carried the old `30s`. Worst case for one live operation is 3 attempts x 8s plus ~1.5s of backoff, about 26s — comfortably inside the 90s backstop even if several operations exhaust their attempts at once. ### `REPO_POLICIES.md` is re-vendored, not hand-edited The file is org-canonical, so it was **copied byte-for-byte** from `prompts/REPO_POLICIES.md` on `sneak/prompts` branch `org-wide-60s-test-cap` (commit `52b5192`) rather than reworded to approximately the same thing. Verified: ``` $ cmp prompts/REPO_POLICIES.md dnswatcher/REPO_POLICIES.md && echo identical identical $ sha256sum REPO_POLICIES.md bcf11c312a1bee18a0e937eb412b51914411c1ab23308b8362409f3f88379ff7 ``` **What that byte-identity does and does not certify.** The source branch `org-wide-60s-test-cap` is an **unmerged proposal** — sneak/prompts#42 — not `prompts` `main`. So, precisely: - The **60s hard cap and 20s improvement-bug tier ARE the owner's ruling** (sneak/prompts#41 (comment)). - The **`90s` backstop is our own proposed number and is NOT ratified** (sneak/prompts#41 (comment)). - The vendored text is therefore **the proposed canonical text, pending** sneak/prompts#42. If that PR lands with different numbers, this file must be re-vendored to match; it should not be hand-edited here either way. **Known mismatch with this repo's actual state, recorded not papered over.** Re-vendoring also picked up the paragraph at `REPO_POLICIES.md:266-271` mandating that canonical golangci-lint be installed commit-pinned via `go install ...@c0d3ddc9...`. This repo does **not** comply with that mechanism: `cc86473` in this same PR made linting Docker-only, and `script/bootstrap` now installs golangci-lint nowhere. The **version and commit match** (`v2.12.2` / `c0d3ddc9`); the **installation mechanism does not**. The vendored file is org-canonical and must not be edited downstream, so this is being raised upstream for the org text to accommodate Docker-only linting rather than patched here. `TESTING.md`'s stale "within the 30-second target" follows to 60. That edit is deliberately a single line so it merges cleanly when #97 lands. ### Verification All runs through `make` / `script/` entrypoints only; lint runs in Docker. **Ten consecutive `make check` runs, all green, none served from cache.** Go's test cache will happily report `ok pkg (cached)` without executing anything, which proves nothing about nondeterminism, so every run was forced to actually execute and each log was checked for zero `(cached)` lines: ``` check#1 exit=0 wall=32s resolver=2.895s fails=0 cached=0 check#2 exit=0 wall=27s resolver=2.872s fails=0 cached=0 check#3 exit=0 wall=47s resolver=2.729s fails=0 cached=0 check#4 exit=0 wall=36s resolver=2.885s fails=0 cached=0 check#5 exit=0 wall=32s resolver=2.899s fails=0 cached=0 check#6 exit=0 (harness tests added) fails=0 cached=0 check#7 exit=0 wall=41s resolver=2.891s fails=0 cached=0 check#8 exit=0 wall=36s resolver=2.871s fails=0 cached=0 check#9 exit=0 wall=26s resolver=2.846s fails=0 cached=0 check#10 exit=0 wall=46s resolver=2.833s fails=0 cached=0 ``` After the rework commit `87bce43`, `make check` green again end to end, zero `(cached)` test lines, Docker lint stage demonstrably executed rather than served from cache: ``` exit=0 cached=0 #8 [deps 4/4] RUN go mod download #8 CACHED #10 [lint 2/2] RUN golangci-lint run --config .golangci.yml ./... #10 30.72 0 issues. ``` The Docker lint stage was confirmed to execute rather than cache on each run: ``` #8 [deps 4/4] RUN go mod download #8 CACHED #9 [lint 1/2] COPY . . #9 DONE 0.6s #10 [lint 2/2] RUN golangci-lint run --config .golangci.yml ./... #10 DONE 39.7s ``` **`make test` wall time: 3.6-4.1s** across three timed uncached runs (`4093ms`, `3649ms`, `3729ms`). `internal/resolver` went from 2.0s to ~2.9s — the concurrency gate's cost. That is inside the 20s target, so no improvement bug is owed under the new two-tier rule. **Honest note on what these runs do and do not prove.** Live DNS was healthy throughout: **no live-DNS retry fired even once**, and no flake was observed either before or after the change (six pre-change baseline runs were also clean). So these runs demonstrate the change is not itself flaky and does not slow the suite; they do **not** demonstrate recovery from a real DNS failure, because no real DNS failure occurred. The original flakiness is *not reproduced* rather than *shown fixed*. The retry path is instead proven by `TestRetryLiveRecoversFromTransientFailure`, the only source of the single `retrying in 500ms` line in each log: ``` livedns_harness_test.go:90: transient: attempt 1 of 3 failed (no answer from live DNS), retrying in 500ms ``` ### Observation, not acted on The single most effective remaining lever against root-server rate limiting would be to stop `queryServers` always trying `rootServerList()` in the same order, so that load spreads across all thirteen roots instead of concentrating on `a.root-servers.net`. That is **production** code and this issue scopes the work as test-side, so it was left alone rather than changed quietly. It is now tracked for the owner's decision at #138. ### Interaction with #97 `TESTING.md` and `internal/resolver/resolver_test.go` auto-merge — that PR touches `resolver_test.go` only at the import block and the final timeout-test section, while this change touches the body of the file and adds two new files, and it leaves the mock-`DNSClient` timeout test at the tail of `resolver_test.go` entirely alone since removing it is that PR's job. `TODO.md` does conflict; that PR is already labelled `needs-rebase`, so this adds nothing material to its rebase. - **Go's test cache disabled, so every `make test` actually queries live DNS** — #139 `script/test` did not pass `-count=1`, so on an unchanged tree Go served the whole suite from cache: exit 0 in ~0.2s, every package marked `(cached)`, and not one DNS query made. This repo's suite exists to exercise live resolution on every run (`TESTING.md`), so that green asserted nothing — and it is exactly the green used as evidence that a flakiness fix works, since "run it a few times" stops being runs after the first. It had already misled two agents, each of whom forced uncached runs by hand. `-count=1` now disables caching on every invocation. **The conditional verbose rerun was missing and is added here.** `REPO_POLICIES.md` mandates it; the primary run had been unconditionally `-v`, which is the failure mode the policy exists to prevent (unreadable CI and `docker build` logs on success). Tests now run quiet, and only a failure triggers the `-v` rerun. Two properties matter and both are covered: the rerun carries `-count=1` too, so it cannot replay a cached copy of the failure it is meant to diagnose; and its exit status is discarded in favour of a forced `1`, so a flake that passes the second time cannot turn the build green — the first failure already proved the suite broken. **`-timeout 90s` untouched.** It is a deliberate backstop that must strictly exceed the 60s hard cap. **No special-casing for the Docker build**, which reaches the same script via `RUN make test`: a fresh container's test cache is empty, so `-count=1` is a no-op there, and carving out an exception would only create a second code path that could drift. ### Verification **Proven from a warm cache, not a cold one.** The suite was run first to populate the cache, and the pre-change state confirmed: ``` ok sneak.berlin/go/dnswatcher/internal/config (cached) coverage: 92.6% of statements ok sneak.berlin/go/dnswatcher/internal/resolver (cached) coverage: 77.1% of statements ...8 of 8 packages (cached)... real 0m0.203s ``` With the change applied to that same warm cache, three back-to-back runs on an unchanged tree, **zero `(cached)` markers** in all three: ``` # run 1 # run 2 ok .../internal/config 1.045s ok .../internal/config 1.040s ok .../internal/handlers 1.029s ok .../internal/handlers 1.021s ok .../internal/notify 1.148s ok .../internal/notify 1.252s ok .../internal/portcheck 1.026s ok .../internal/portcheck 1.025s ok .../internal/resolver 3.005s ok .../internal/resolver 2.846s ok .../internal/state 1.078s ok .../internal/state 1.097s ok .../internal/tlscheck 1.067s ok .../internal/tlscheck 1.079s ok .../internal/watcher 1.566s ok .../internal/watcher 1.544s real 0m4.174s real 0m4.015s # run 3: grep -c '(cached)' => 0 real 0m4.519s ``` **Measured uncached wall time: 4.0-4.5s** (was ~0.2s served from cache). Inside the 20s target, so no improvement bug is owed under the two-tier rule at sneak/prompts#41 (comment). **`-count=1` composes with `-race` and `-cover`**: both still present in the primary run, and the per-package coverage percentages above are identical to the pre-change values. **The failure path was exercised, not assumed.** A purpose-built flaky test that fails on its first run and passes every run after (marker file kept outside the module, so the tree stays byte-identical and a cached result would be served if caching were on) was run through the script: ``` exit code: 1 --- FAIL: TestFlaky (0.00s) FAIL flakeproof 0.013s --- Rerunning with -v for details --- --- PASS: TestFlaky (0.00s) ok flakeproof 1.014s ``` Quiet failure, verbose rerun that genuinely re-executed (it passed, so it did not replay the cached `FAIL`), and exit `1` regardless of the rerun passing. Scratch module removed afterwards. **`make check` green**, exit 0, with the Docker lint stage demonstrably executed rather than served from cache: ``` #8 [deps 4/4] RUN go mod download #8 CACHED #10 [lint 2/2] RUN golangci-lint run --config .golangci.yml ./... #10 21.36 0 issues. #10 DONE 26.2s ``` `README.md` and `TESTING.md` record why the cache is waived, and `TODO.md` is updated in the same commit. ### Question for the owner, not filed as a defect The Docker lint run emits `The linter 'gomodguard' is deprecated (since v2.12.0) due to: new major version. Replaced by gomodguard_v2.` It is pre-existing and out of this issue's scope. It is not filed as an issue here because `.golangci.yml` tracks the org-canonical config, so switching to `gomodguard_v2` looks like an upstream `sneak/prompts` decision rather than a per-repo fix. Say the word and it gets filed in whichever place you consider canonical. - **MIT `LICENSE` added; README states the licence** — #102 The repo had no licence file at all, so publicly readable code was all-rights-reserved by default and nobody could legally use it. `LICENSE` was also the last file missing from `REPO_POLICIES.md`'s required minimum. MIT, by standing org policy rather than a per-repo call: any public repo lacking a licence gets MIT, and a private one with no licence is already all-rights-reserved. `sneak/dnswatcher` is public (`private: false` on the Gitea repo record). `README.md`'s first line now names the licence, per the Description requirement, and the License section states MIT and points at the file instead of recording the decision as pending. `TODO.md` updated in the same commit. ### Verification `LICENSE` is the canonical MIT text byte-for-byte with only the copyright line filled in (`Copyright (c) 2026 sneak`) — no clauses added, removed, reworded, or reflowed. It was not typed from memory: the file was copied from an existing verbatim MIT template on disk and only the copyright line edited (`diff` against that template shows that one line and nothing else), then the result was word-diffed against SPDX `MIT.txt` fetched from `spdx/license-list-data`, ignoring only line wrapping and the placeholder — identical. `make fmt` did **not** touch `LICENSE`, and cannot: `script/fmt` runs `gofmt -s -w .` and `goimports -w .` only, with no prettier or markdown step in the repo, so no exclusion was needed. `make check` green, exit 0. Tests executed rather than replayed (zero `(cached)` lines, `internal/resolver 3.098s`), and the Docker lint stage ran rather than cached: ``` #10 [deps 4/4] RUN go mod download #10 CACHED #12 [lint 2/2] RUN golangci-lint run --config .golangci.yml ./... #12 36.44 0 issues. #12 DONE 36.7s ``` - **Comment-only corrections in `script/bootstrap`, `script/cibuild`, and `Dockerfile.lint`** — #137 Follow-up to this PR's own review. Nothing executable changed: the diff touches comment lines, one warning string, and `TODO.md`. - `script/bootstrap`'s header justified the pinned `goimports` install by claiming `script/fmt-check` runs it on the host. Verified against the script: `script/fmt-check` runs `gofmt -l .` and nothing else. The header now credits `script/fmt` alone. That `fmt-check` does not verify goimports at all is #119 and was deliberately left alone. - `script/cibuild`'s header still said the `Dockerfile` runs `make check`. It now describes the current file: lint stage runs `make fmt-check` and `golangci-lint`, builder stage runs `make test` and `make build`. - The `docker`-missing warning was three fragments, each re-prefixed with `bootstrap:` mid-clause. Now one sentence: `bootstrap: WARNING: docker not found; install it to run make lint and make docker.` - `Dockerfile.lint`'s comment explained why `golangci-lint config verify` is omitted but read as though the omission were free. It now states the residual risk: unknown top-level keys in `.golangci.yml` are silently ignored, so a mistyped or wrong-schema key lints clean while applying nothing. `config verify` was **not** added — the network-dependence reasoning stands. ### Verification `make check` green, exit 0. Tests executed rather than replayed (zero `(cached)` lines, `internal/resolver 2.820s`), Docker lint stage executed rather than cached: ``` #8 [deps 4/4] RUN go mod download #8 CACHED #10 [lint 2/2] RUN golangci-lint run --config .golangci.yml ./... #10 13.30 0 issues. ``` Comment-only confirmed by reading the whole diff: no statement, flag, or command changed anywhere. --- ## Issues closed by this merge The commits on `next` each carry a bare `(closes #N)` in their subject, but the references in the prose above are full URLs, which Gitea's auto-close parser does not act on. Listing them here in bare form so the merge to `main` definitively closes them rather than leaving them open to be re-picked up as idle work: Closes #93 Closes #102 Closes #134 Closes #137 Closes #139 Co-authored-by: sneak <sneak@sneak.berlin> Reviewed-on: #136 Co-authored-by: clawbot <clawbot@noreply.example.org> Co-committed-by: clawbot <clawbot@noreply.example.org>
417 lines
21 KiB
Markdown
417 lines
21 KiB
Markdown
---
|
|
title: Repository Policies
|
|
last_modified: 2026-08-07
|
|
---
|
|
|
|
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 `@<commit-sha>`). 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@<version> --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/<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 types
|
|
`make<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 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 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 in the Makefile (`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:
|
|
|
|
```makefile
|
|
test:
|
|
@<test-command> || \
|
|
{ echo "--- Rerunning with -v for details ---"; \
|
|
<test-command-with-v>; exit 1; }
|
|
```
|
|
|
|
Go example:
|
|
|
|
```makefile
|
|
test:
|
|
@go test -timeout 90s -race -cover ./... || \
|
|
{ echo "--- Rerunning with -v for details ---"; \
|
|
go test -timeout 90s -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`. The
|
|
canonical golangci-lint version is v2.12.2 (released 2026-05-06), installed
|
|
commit-pinned via
|
|
`go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@c0d3ddc9cf3faa61a4e378e879ece580256d76e5`.
|
|
|
|
- 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/<name>`. 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/<path>`.
|
|
|
|
- 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`
|