Compare commits

Author SHA1 Message Date
sneak 114ba0a29f Disable the deprecated gomodguard linter (closes #116)
check / check (push) Failing after 2s
golangci-lint v2.12 deprecates gomodguard in favour of gomodguard_v2.
With `default: all` both ran, and every lint run printed the
deprecation warning. Disable the old name, the same way wsl is
disabled for wsl_v5; gomodguard_v2 stays enabled. The old linter had
no settings here, so there is nothing to carry over.

Model: opus-5-5
2026-10-04 08:06:44 +00:00
clawbot 64eb5cbd40 Scanner status tests no longer depend on a 100 ms timer (closes #124)
check / check (push) Failing after 3s
The two status-send tests now call the send directly on a channel nobody
receives from, so a blocking send hangs the test into its timeout instead
of racing a 100 ms timer that a loaded host can miss.

The gpg test for a child holding gpg's output had the same problem: its
100 ms deadline could fire before the fake gpg wrote the PID of sleep,
and the cleanup then failed. The fake gpg now writes that PID to a named
pipe, and the test cancels only after reading it. It then waits up to
10 s for the call, so a call that waits for sleep fails the test and the
cleanup still kills sleep.

Model: opus-5-5
2026-10-04 09:48:51 +02:00
clawbot 45eac1f6f8 Run all linting in Docker via the Dockerfile lint stage (closes #90)
check / check (push) Failing after 3s
script/lint now builds only the lint stage of the main Dockerfile
(docker build --no-cache --target lint), whose build runs the linter, so
a successful build is a clean lint. It is uncached because a cached
build runs no linter, and a trap removes the image it tagged; the tag
carries the process ID so concurrent runs do not collide. The lint stage
calls golangci-lint directly, since make lint now needs Docker. Nothing
installs or runs golangci-lint on the host any more: bootstrap and the
Makefile drop the install, and script/fmt drops golangci-lint run --fix.

Model: opus-5-5
2026-10-04 09:31:54 +02:00
clawbot b91e92b070 Build a static binary so the scratch image runs (closes #126)
check / check (push) Failing after 1s
The final stage is scratch, which has no C library, but the builder
compiled mfer with cgo on (the golang image's default). The binary
imports net (mfer's own HTTP code does, and so does google/uuid), so
with cgo on it came out dynamically linked and the image could not
start. The image's go build now sets CGO_ENABLED=0; nothing in mfer
needs cgo. A new builder step runs ldd on the binary and fails the
build unless it reports a static executable.

Model: opus-5-5
2026-10-04 08:48:52 +02:00
9 changed files with 76 additions and 86 deletions
+1
View File
@@ -16,6 +16,7 @@ linters:
- depguard # Dependency allow/block lists - depguard # Dependency allow/block lists
- godot # Requires comments to end with periods - godot # Requires comments to end with periods
- wsl # Deprecated, replaced by wsl_v5 - wsl # Deprecated, replaced by wsl_v5
- gomodguard # Deprecated, replaced by gomodguard_v2
- wrapcheck # Too verbose for internal packages - wrapcheck # Too verbose for internal packages
- varnamelen # Short names like db, id are idiomatic Go - varnamelen # Short names like db, id are idiomatic Go
settings: settings:
+7 -2
View File
@@ -14,7 +14,9 @@ RUN touch mfer/mf.pb.go
# Go half of fmt-check only: this image has no node, so no prettier. The # Go half of fmt-check only: this image has no node, so no prettier. The
# markdown half runs in the mdfmt stage below. # markdown half runs in the mdfmt stage below.
RUN make fmt-check-go RUN make fmt-check-go
RUN make lint # The linter directly, not `make lint`: script/lint builds this stage, and
# there is no docker inside this build.
RUN golangci-lint run --config .golangci.yml ./...
# Markdown/JSON format stage — prettier needs node, which the Go images # Markdown/JSON format stage — prettier needs node, which the Go images
# do not have. node:22.17.0-bookworm-slim (2026-08-09); ships node # do not have. node:22.17.0-bookworm-slim (2026-08-09); ships node
@@ -67,7 +69,10 @@ RUN version="${VERSION:-$(git describe --tags --always)}"; \
exit 1; \ exit 1; \
fi; \ fi; \
cd cmd/mfer && \ cd cmd/mfer && \
go build -tags urfave_cli_no_docs -ldflags "-X main.Gitrev=$version" -o /mfer . CGO_ENABLED=0 go build -tags urfave_cli_no_docs -ldflags "-X main.Gitrev=$version" -o /mfer .
# Fail unless /mfer is statically linked: scratch has no C library to run it.
RUN ldd /mfer 2>&1 | grep -q 'not a dynamic executable'
FROM scratch FROM scratch
COPY --from=builder /mfer /mfer COPY --from=builder /mfer /mfer
-3
View File
@@ -58,9 +58,6 @@ fmt-check-md:
hooks: hooks:
@script/install-precommit @script/install-precommit
devprereqs:
which golangci-lint || go install -v github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2
mfer/mf.pb.go: mfer/mf.proto mfer/mf.pb.go: mfer/mf.proto
cd mfer && go generate . cd mfer && go generate .
+12 -11
View File
@@ -65,9 +65,9 @@ standard: normalized scripts in `script/` are the entrypoints for the
development workflow, and the Makefile targets are thin shims that call them. We development workflow, and the Makefile targets are thin shims that call them. We
provide: provide:
- `script/bootstrap` — install all dependencies (Go, golangci-lint, Go module - `script/bootstrap` — install all dependencies (Go, Go module download, and
download, and node/yarn plus the prettier version pinned in node/yarn plus the prettier version pinned in `package.json`/`yarn.lock`),
`package.json`/`yarn.lock`), idempotently idempotently; golangci-lint is not installed, it runs only in Docker
- `script/setup` — make a fresh clone ready for development: runs - `script/setup` — make a fresh clone ready for development: runs
`script/bootstrap`, then `script/install-precommit` `script/bootstrap`, then `script/install-precommit`
- `script/projectname` — output the project name (`mfer`); used by other scripts - `script/projectname` — output the project name (`mfer`); used by other scripts
@@ -77,9 +77,11 @@ provide:
- `script/fuzz` — fuzz the manifest parser for one minute; run by hand - `script/fuzz` — fuzz the manifest parser for one minute; run by hand
(`make fuzz`), never by CI, while `script/test` runs its committed seed corpus (`make fuzz`), never by CI, while `script/test` runs its committed seed corpus
as ordinary tests as ordinary tests
- `script/lint` — run `golangci-lint` and verify `gofmt` cleanliness - `script/lint` — run `golangci-lint` in Docker: builds only the `lint` stage of
- `script/fmt` — format all code and docs (writes): `gofumpt`, the `Dockerfile` (the Go format check, then the linter), uncached so it runs
`golangci-lint run --fix`, and `script/prettier --write` every time, then removes the image
- `script/fmt` — format all code and docs (writes): `gofumpt` and
`script/prettier --write`
- `script/prettier` — run prettier over the repository's canonical file set - `script/prettier` — run prettier over the repository's canonical file set
(Markdown and JSON, minus `.prettierignore`) in the given mode, `--write` or (Markdown and JSON, minus `.prettierignore`) in the given mode, `--write` or
`--check`; the single definition of that file set, so `script/fmt` and `--check`; the single definition of that file set, so `script/fmt` and
@@ -105,11 +107,10 @@ yet. Primary development happens on a privately-run Gitea instance at
[tracked there](https://git.eeqj.de/sneak/mfer/issues). [tracked there](https://git.eeqj.de/sneak/mfer/issues).
Changes must always be formatted with a standard `go fmt`, syntactically valid, Changes must always be formatted with a standard `go fmt`, syntactically valid,
and must pass the linting defined in the repository (presently only the and must pass the linting defined in the repository's `.golangci.yml`, which
`golangci-lint` defaults), which can be run with a `make lint`. The `main` `make lint` runs in Docker. The `main` branch is protected and all changes must
branch is protected and all changes must be made via be made via [pull requests](https://git.eeqj.de/sneak/mfer/pulls) and pass CI to
[pull requests](https://git.eeqj.de/sneak/mfer/pulls) and pass CI to be merged. be merged. Any changes submitted to this project must also be
Any changes submitted to this project must also be
[WTFPL-licensed](https://wtfpl.net) to be considered. [WTFPL-licensed](https://wtfpl.net) to be considered.
See [`REPO_POLICIES.md`](REPO_POLICIES.md) for detailed coding standards, See [`REPO_POLICIES.md`](REPO_POLICIES.md) for detailed coding standards,
+40 -24
View File
@@ -10,6 +10,7 @@ import (
"path/filepath" "path/filepath"
"strconv" "strconv"
"strings" "strings"
"syscall"
"testing" "testing"
"time" "time"
@@ -425,36 +426,51 @@ func TestGPGTimeoutKillsGPG(t *testing.T) {
assert.Contains(t, err.Error(), "gpg sign failed: gpg timed out") assert.Contains(t, err.Error(), "gpg sign failed: gpg timed out")
} }
// TestGPGTimeoutWhenChildHoldsOutput uses a fake gpg that runs sleep as a // TestGPGCancelWhenChildHoldsOutput uses a fake gpg that runs sleep as a
// child instead of exec-ing it, the way a wrapper script around the real // child instead of exec-ing it, the way a wrapper script around the real
// gpg might. Killing the fake gpg leaves sleep holding its stdout and // gpg might. Killing the fake gpg leaves sleep holding its stdout and
// stderr open; the call must still return shortly after the deadline // stderr open; the call must still return once ctx ends instead of waiting
// instead of waiting for sleep to exit. The fake gpg writes the process ID // for sleep to exit. The fake gpg writes the process ID of sleep to a named
// of sleep to a file so that the test can kill it before returning. // pipe; the test ends ctx only after reading it, so sleep is running by
func TestGPGTimeoutWhenChildHoldsOutput(t *testing.T) { // then, and kills sleep before returning.
pidFile := filepath.Join(t.TempDir(), "sleep.pid") func TestGPGCancelWhenChildHoldsOutput(t *testing.T) {
pidPipe := filepath.Join(t.TempDir(), "sleep.pid")
require.NoError(t, syscall.Mkfifo(pidPipe, 0o600))
// sleep outlasts the 10 s wait below, so a call that waits for it fails.
t.Setenv("PATH", fakeGPGPath(t, t.Setenv("PATH", fakeGPGPath(t,
"#!/bin/sh\nsleep 3 &\necho $! >'"+pidFile+"'\nwait\n")) "#!/bin/sh\nsleep 60 &\necho $! >'"+pidPipe+"'\nwait\n"))
t.Cleanup(func() {
pid, err := os.ReadFile(pidFile) //nolint:gosec // G304: path inside t.TempDir()
require.NoError(t, err)
n, err := strconv.Atoi(strings.TrimSpace(string(pid))) ctx, cancel := context.WithCancel(context.Background())
require.NoError(t, err)
sleep, err := os.FindProcess(n)
require.NoError(t, err)
require.NoError(t, sleep.Kill())
})
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel() defer cancel()
start := time.Now() signErr := make(chan error, 1)
_, err := gpgSign(ctx, []byte("data"), GPGKeyID("any"))
require.ErrorIs(t, err, context.DeadlineExceeded) go func() {
assert.Less(t, time.Since(start), 3*time.Second, _, err := gpgSign(ctx, []byte("data"), GPGKeyID("any"))
"the call waited for the child holding gpg's output to exit") signErr <- err
}()
pid, err := os.ReadFile(pidPipe) //nolint:gosec // G304: path inside t.TempDir()
require.NoError(t, err)
n, err := strconv.Atoi(strings.TrimSpace(string(pid)))
require.NoError(t, err)
sleep, err := os.FindProcess(n)
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, sleep.Kill()) })
cancel()
// The call should return about gpgWaitDelay (one second) after the
// cancel. 10 s is far above that and well under the 30 s test timeout,
// which would abort the whole package before the cleanup kills sleep.
select {
case err := <-signErr:
require.ErrorIs(t, err, context.Canceled)
case <-time.After(10 * time.Second):
t.Fatal("the call waited for the child holding gpg's output to exit")
}
} }
// TestBuildPassesContextToSigning checks that a caller can cancel the gpg // TestBuildPassesContextToSigning checks that a caller can cancel the gpg
+4 -31
View File
@@ -304,46 +304,19 @@ func TestScannerEnumerateFS(t *testing.T) {
func TestSendEnumerateStatusNonBlocking(t *testing.T) { func TestSendEnumerateStatusNonBlocking(t *testing.T) {
t.Parallel() t.Parallel()
// Channel with no buffer - send should not block // Nobody receives, so a blocking send would hang the test into its timeout.
ch := make(chan EnumerateStatus) ch := make(chan EnumerateStatus)
// This should not block sendEnumerateStatus(ch, EnumerateStatus{FilesFound: 1})
done := make(chan bool)
go func() {
sendEnumerateStatus(ch, EnumerateStatus{FilesFound: 1})
done <- true
}()
select {
case <-done:
// Success - did not block
case <-time.After(100 * time.Millisecond):
t.Fatal("sendEnumerateStatus blocked on full channel")
}
} }
func TestSendScanStatusNonBlocking(t *testing.T) { func TestSendScanStatusNonBlocking(t *testing.T) {
t.Parallel() t.Parallel()
// Channel with no buffer - send should not block // Nobody receives, so a blocking send would hang the test into its timeout.
ch := make(chan ScanStatus) ch := make(chan ScanStatus)
done := make(chan bool) sendScanStatus(ch, ScanStatus{ScannedFiles: 1})
go func() {
sendScanStatus(ch, ScanStatus{ScannedFiles: 1})
done <- true
}()
select {
case <-done:
// Success - did not block
case <-time.After(100 * time.Millisecond):
t.Fatal("sendScanStatus blocked on full channel")
}
} }
func TestSendStatusNilChannel(t *testing.T) { func TestSendStatusNilChannel(t *testing.T) {
+1 -6
View File
@@ -140,12 +140,7 @@ main() {
# ---- Go repos ---- # ---- Go repos ----
if missing go; then pkg_install go golang go go; fi if missing go; then pkg_install go golang go go; fi
# golangci-lint: packaged in nix, brew, and apk. On apt there is no # No golangci-lint: script/lint runs it in Docker only.
# package: download a specific release archive from GitHub and
# verify its hash (verify_sha256), never curl | sh.
if missing golangci-lint; then
pkg_install golangci-lint golangci-lint golangci-lint golangci-lint
fi
go mod download go mod download
# ---- Python repos ---- # ---- Python repos ----
-1
View File
@@ -19,7 +19,6 @@ main() {
cd "$ROOT" cd "$ROOT"
ensure_pb ensure_pb
gofumpt -l -w mfer internal cmd gofumpt -l -w mfer internal cmd
golangci-lint run --fix
# Markdown and JSON, over the same file set script/fmt-check verifies. # Markdown and JSON, over the same file set script/fmt-check verifies.
"$SCRIPT_DIR/prettier" --write "$SCRIPT_DIR/prettier" --write
} }
+11 -8
View File
@@ -1,17 +1,20 @@
#!/bin/sh #!/bin/sh
# script/lint: run the linter. # script/lint: run golangci-lint, in Docker only. Builds the lint stage of
# the Dockerfile, whose build runs the linter, so a successful build is a
# clean lint. --no-cache because a cached build runs no linter. The image
# is removed afterwards, whatever the outcome.
set -eu set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() { main() {
cd "$ROOT" cd "$ROOT"
golangci-lint run # Tagged per run, so concurrent runs never remove each other's image.
if [ -n "$(gofmt -l .)" ]; then image="$("$SCRIPT_DIR/projectname")-lint:$$"
echo "gofmt: files need formatting:" >&2 # A failed build leaves no image, so there is nothing to remove then.
gofmt -l . >&2 trap 'docker image rm "$image" >/dev/null 2>&1 || true' EXIT INT TERM
exit 1 docker build --no-cache --target lint -t "$image" .
fi
} }
main "$@" main "$@"