Compare commits

...
2 Commits
Author SHA1 Message Date
clawbot 2d996df335 Run lint and tests as phases of the Dockerfile (closes #5)
check / check (push) Successful in 2m33s
The Dockerfile gets a lint phase on the pinned golangci-lint v2.12.2
image and a test phase on the Debian Go image. The tests run as an
unprivileged user, because root reads a file with mode 0000 and the
permission test then fails. The build stage copies a file from each
phase, so no build finishes unless both pass, and it no longer runs
make check. script/lint and script/test each build their phase,
uncached and tagged; script/cibuild bootstraps, runs script/check, then
builds the image. script/bootstrap no longer installs golangci-lint.
README.md and TODO.md describe the new setup.

Model: opus-5-5
2026-10-06 04:32:11 +00:00
clawbot 4fd857bc32 Keep going past unreadable files with check --continue (closes #11)
check / check (push) Successful in 2m1s
With --continue, check stopped at the first file whose content or
checksum attribute it could not read, and at the first directory it
could not list. Each of these now counts as failed, its error, which
names the path, goes to stderr, and the walk goes on; the run still
exits non-zero. The count that sizes the progress bar leaves such a
path out, so the bar stays. The walk and the count, shared with sum
and clear, take the continue setting; those commands pass false and
still stop at the first error. Without --continue the first such error
still stops the run, and the summary printed before it now counts an
unreadable checksum attribute as failed, as it already did for
unreadable content.

Model: opus-5-5
2026-10-06 05:44:04 +02:00
10 changed files with 199 additions and 69 deletions
+34 -26
View File
@@ -1,35 +1,43 @@
# Build stage
# golang 1.25-alpine, 2026-02-28
FROM golang@sha256:f6751d823c26342f9506c03797d2527668d095b0a15f1862cddb4d927a7a4ced AS builder
RUN apk add --no-cache git make gcc musl-dev binutils-gold
# golangci-lint v2.12.2, 2026-10-05
RUN go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@c0d3ddc9cf3faa61a4e378e879ece580256d76e5
# goimports v0.42.0
RUN go install golang.org/x/tools/cmd/goimports@009367f5c17a8d4c45a961a3a509277190a9a6f0
# Lint phase
# golangci/golangci-lint:v2.12.2, 2026-10-05
FROM golangci/golangci-lint@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS lint
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN golangci-lint run --config .golangci.yml ./...
# Test phase. -race needs cgo and so a C compiler, which the Debian Go
# image ships and the alpine one does not. The tests run as an
# unprivileged user: root can read a file with mode 0000, so the
# permission test would fail.
# golang:1.25.7-trixie, 2026-10-06
FROM golang@sha256:2b174ffcf56c7ad0c47d30d2630693265639ddf2a5141149c2da34db921791b4 AS test
RUN useradd --create-home testuser
USER testuser
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go test -timeout 90s -race -cover ./... || \
{ echo "--- Rerunning with -v for details ---"; \
go test -timeout 90s -race -v ./...; exit 1; }
# Build stage. Nothing is wanted from either phase above; the copies
# are what make BuildKit build them first, so this stage cannot run
# unless lint and test passed.
# golang 1.25-alpine, 2026-02-28
FROM golang@sha256:f6751d823c26342f9506c03797d2527668d095b0a15f1862cddb4d927a7a4ced AS builder
COPY --from=lint /src/go.sum /dev/null
COPY --from=test /src/go.sum /dev/null
RUN apk add --no-cache git make
# A tar-stream context keeps the sender's file owners, which git refuses.
RUN git config --system --add safe.directory /src
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# Run the checks as an unprivileged user. Root bypasses file mode bits, which
# would make the permission tests (expecting EACCES on a 0000 file) spuriously
# pass with no error. Caches live under /tmp (world-writable) so the user needs
# no home directory of its own.
ENV GOCACHE=/tmp/gocache
ENV XDG_CACHE_HOME=/tmp/xdgcache
RUN adduser -D -u 1000 builder && chown -R builder:builder /src /go
USER builder
# Run all checks - build fails if any check fails
RUN make check
# Build the binary (still as the unprivileged user: it owns /src, so git VCS
# stamping sees consistent ownership).
#
# The version stamped into the binary: the VERSION build argument when one
# is given, otherwise `git describe --tags --always` of the .git the build
# context carries: the tag on a tagged commit, tag-N-gHASH on a commit after
+3 -1
View File
@@ -95,7 +95,9 @@ Future improvements under consideration:
* Author & maintainer: **sneak** – <sneak@sneak.berlin>
* Issues / PRs: <https://git.eeqj.de/sneak/attrsum/>
* Code must pass `go vet`, `go test ./...`, and `go fmt`.
* Code must pass `make check`, which runs the tests and golangci-lint as
phases of the `Dockerfile` (Docker is required) and checks formatting
with `gofmt`.
* No CLA; contributions are under WTFPL v2.
---
+18 -8
View File
@@ -17,13 +17,22 @@ have landed since the tag.
# Next Step
Policy scaffold commit: add LICENSE, REPO_POLICIES.md, .editorconfig,
.golangci.yml, and a comprehensive .gitignore (currently only the
attrsum binary), and extend the Makefile (only test/build/clean/try
today) with lint, fmt, fmt-check, check, and hooks targets.
Re-vendor the canonical files from `sneak/prompts` at `dd4027b`
(https://git.eeqj.de/sneak/attrsum/issues/13): add `REPO_POLICIES.md`
and `.editorconfig`, refresh `.gitignore` (only the `attrsum` binary
today), `.dockerignore`, `.gitea/workflows/check.yml` and
`.golangci.yml`, and move the lint phase to golangci-lint v2.14.0.
# Completed Steps
* 2026-10-06: lint and test run as phases of the `Dockerfile`, and the
build stage depends on both; `script/lint` and `script/test` each
build their phase with `--no-cache`; `script/cibuild` bootstraps,
runs `script/check`, then builds the image; golangci-lint is no
longer installed on the host
* 2026-10-06: `check --continue` keeps going past a file or directory
it cannot read: it counts it as failed, prints the error and the
path on stderr, and checks the rest of the tree
* 2026-10-05: golangci-lint settings take effect: canonical
`.golangci.yml` (v2 layout, settings under `linters.settings`),
golangci-lint pinned at v2.12.2 in `Dockerfile` and
@@ -50,11 +59,12 @@ today) with lint, fmt, fmt-check, check, and hooks targets.
# Future Steps
* Add .gitea/workflows/check.yml
* Restructure README.md into the standard sections: Description,
Getting Started, Rationale, Design, TODO, License, Author (Getting
Started, Why?, TODO, License exist; Description, Design, Author are
missing)
Getting Started, Entrypoints, Rationale, Design, TODO, License,
Author (Getting Started, Why?, TODO, License exist; Description,
Entrypoints, Design, Author are missing)
* Add a `LICENSE` file matching the README's WTFPL v2; sneak's to add,
not an agent's
* Tag a patch release to ship the 2026-02-02 correctness fixes
* Dry-run mode (--dry-run, -n): show what would be done without making
changes (from README TODO)
+36 -16
View File
@@ -179,9 +179,9 @@ type processFunc func(
// countAndBar counts the files under paths and returns a progress bar sized
// to that total. It always returns either a non-nil bar or a non-nil error.
func countAndBar(
opts *options, paths []string, desc string,
opts *options, paths []string, desc string, cont bool,
) (*progressbar.ProgressBar, error) {
total, err := countFilesMultiple(opts, paths)
total, err := countFilesMultiple(opts, paths, cont)
if err != nil {
return nil, err
}
@@ -211,7 +211,7 @@ func runOverPaths(
var bar *progressbar.ProgressBar
if !opts.quiet {
bar, err = countAndBar(opts, paths, desc)
bar, err = countAndBar(opts, paths, desc, false)
if err != nil {
return err
}
@@ -268,7 +268,7 @@ func newSumCmd(opts *options) *cobra.Command {
func processSumAdd(
opts *options, dir string, stats *Stats, bar *progressbar.ProgressBar,
) error {
return walkAndProcess(opts, dir, stats, bar,
return walkAndProcess(opts, dir, false, stats, bar,
func(p string, info os.FileInfo, s *Stats) error {
if hasXattr(p, checksumKey) {
atomic.AddInt64(&s.FilesSkipped, 1)
@@ -290,7 +290,7 @@ func processSumAdd(
func processSumUpdate(
opts *options, dir string, stats *Stats, bar *progressbar.ProgressBar,
) error {
return walkAndProcess(opts, dir, stats, bar,
return walkAndProcess(opts, dir, false, stats, bar,
func(p string, info os.FileInfo, s *Stats) error {
t, err := readSumTime(p)
if err != nil || info.ModTime().After(t) {
@@ -386,7 +386,7 @@ func newClearCmd(opts *options) *cobra.Command {
func processClear(
opts *options, dir string, stats *Stats, bar *progressbar.ProgressBar,
) error {
return walkAndProcess(opts, dir, stats, bar,
return walkAndProcess(opts, dir, false, stats, bar,
func(p string, info os.FileInfo, s *Stats) error {
cleared, err := clearOne(opts, p)
if err != nil {
@@ -468,7 +468,7 @@ func runCheck(opts *options, args []string, cont bool) error {
var bar *progressbar.ProgressBar
if !opts.quiet {
bar, err = countAndBar(opts, paths, "Verifying checksums")
bar, err = countAndBar(opts, paths, "Verifying checksums", cont)
if err != nil {
return err
}
@@ -502,7 +502,7 @@ func processCheck(
// Track initial failed count to detect failures during this walk.
initialFailed := atomic.LoadInt64(&stats.FilesFailed)
err := walkAndProcess(opts, dir, stats, bar,
err := walkAndProcess(opts, dir, cont, stats, bar,
func(p string, _ os.FileInfo, s *Stats) error {
return checkOne(opts, p, cont, s)
})
@@ -527,7 +527,7 @@ func checkOne(opts *options, p string, cont bool, s *Stats) error {
exp, err := xattr.Get(p, checksumKey)
if err != nil {
if !errors.Is(err, xattr.ENOATTR) {
return err
return unreadable(cont, s, err)
}
return missingChecksum(opts, p, cont, s)
@@ -535,9 +535,7 @@ func checkOne(opts *options, p string, cont bool, s *Stats) error {
act, bytesRead, err := fileMultihash(p)
if err != nil {
atomic.AddInt64(&s.FilesFailed, 1)
return err
return unreadable(cont, s, err)
}
ok := bytes.Equal(exp, act)
@@ -573,6 +571,21 @@ func missingChecksum(opts *options, p string, cont bool, s *Stats) error {
return errVerification
}
// unreadable counts a file or directory that could not be read as failed.
// With --continue it prints err, which names the path, to stderr and
// returns nil so the walk goes on; otherwise it returns err.
func unreadable(cont bool, s *Stats, err error) error {
atomic.AddInt64(&s.FilesFailed, 1)
if !cont {
return err
}
log.Print(err)
return nil
}
// reportCheck prints a per-file verification result when verbose output is on.
func reportCheck(opts *options, p, actual string, ok bool) {
if !opts.verbose || opts.quiet {
@@ -592,12 +605,18 @@ func reportCheck(opts *options, p, actual string, ok bool) {
///////////////////////////////////////////////////////////////////////////////
// countFiles counts the total number of regular files that will be processed.
func countFiles(opts *options, root string) (int64, error) {
// With cont, a path it cannot read is left out of the count instead of ending
// it; the walk that follows reports that path as failed.
func countFiles(opts *options, root string, cont bool) (int64, error) {
var count int64
root = filepath.Clean(root)
err := filepath.Walk(root, func(p string, info os.FileInfo, err error) error {
if err != nil && cont {
return nil
}
if err != nil {
return err
}
@@ -632,11 +651,11 @@ func countFiles(opts *options, root string) (int64, error) {
}
// countFilesMultiple counts files across multiple roots.
func countFilesMultiple(opts *options, roots []string) (int64, error) {
func countFilesMultiple(opts *options, roots []string, cont bool) (int64, error) {
var total int64
for _, root := range roots {
count, err := countFiles(opts, root)
count, err := countFiles(opts, root, cont)
if err != nil {
return total, err
}
@@ -672,6 +691,7 @@ func newProgressBar(total int64, description string) *progressbar.ProgressBar {
func walkAndProcess(
opts *options,
root string,
cont bool,
stats *Stats,
bar *progressbar.ProgressBar,
fn func(string, os.FileInfo, *Stats) error,
@@ -680,7 +700,7 @@ func walkAndProcess(
return filepath.Walk(root, func(p string, info os.FileInfo, err error) error {
if err != nil {
return err
return unreadable(cont, stats, err)
}
skip, skipErr := walkSkip(opts, root, p, info)
+57
View File
@@ -1,6 +1,7 @@
package main
import (
"errors"
"os"
"path/filepath"
"strings"
@@ -271,3 +272,59 @@ func TestPermissionErrors(t *testing.T) {
t.Fatalf("expected permission error on check, got nil")
}
}
func TestCheckContinuePastUnreadable(t *testing.T) {
t.Parallel()
opts := &options{}
dir := t.TempDir()
skipIfNoXattr(t, dir)
writeFile(t, dir, "a.txt", "one")
secret := writeFile(t, dir, "b.txt", "two")
writeFile(t, dir, "c/d.txt", "three")
writeFile(t, dir, "e.txt", "four")
err := processSumAdd(opts, dir, newTestStats(), nil)
if err != nil {
t.Fatalf("add: %v", err)
}
// An unreadable file and an unlistable directory sit between the
// readable files a.txt and e.txt.
sub := filepath.Join(dir, "c")
err = os.Chmod(secret, noPerm)
if err != nil {
t.Fatalf("chmod file: %v", err)
}
defer func() { _ = os.Chmod(secret, filePerm) }()
err = os.Chmod(sub, noPerm)
if err != nil {
t.Fatalf("chmod dir: %v", err)
}
defer func() { _ = os.Chmod(sub, dirPerm) }()
stats := newTestStats()
err = processCheck(opts, dir, true, stats, nil)
if !errors.Is(err, errVerification) {
t.Fatalf("expected verification error, got %v", err)
}
if stats.FilesProcessed != 2 || stats.FilesFailed != 2 {
t.Fatalf("expected 2 verified and 2 failed, got %d and %d",
stats.FilesProcessed, stats.FilesFailed)
}
// Without --quiet, runCheck counts the files for the progress bar
// before it checks any, so the count reaches the unlistable directory
// first.
err = runCheck(opts, []string{dir}, true)
if !errors.Is(err, errVerification) {
t.Fatalf("expected verification error from runCheck, got %v", err)
}
}
+5 -7
View File
@@ -3,15 +3,14 @@
# this repo. Idempotent: every install is guarded by a check so already
# installed tools are skipped. Base tooling comes from nix, apt, brew,
# or apk (detected in that order); assumes nothing is present.
# golangci-lint and goimports are installed via `go install` at the same
# pinned commits the Dockerfile uses (never "latest").
# goimports is installed via `go install` at a pinned commit (never
# "latest"). The linter is not installed: it runs only as the lint phase
# of the Dockerfile.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
# Pinned versions, 2026-10-05 (same pins as the Dockerfile)
# golangci-lint v2.12.2
GOLANGCI_LINT_REF="github.com/golangci/golangci-lint/v2/cmd/golangci-lint@c0d3ddc9cf3faa61a4e378e879ece580256d76e5"
# Pinned versions, 2026-10-05
# goimports v0.42.0
GOIMPORTS_REF="golang.org/x/tools/cmd/goimports@009367f5c17a8d4c45a961a3a509277190a9a6f0"
@@ -69,9 +68,8 @@ main() {
if missing make; then pkg_install gnumake make make make; fi
if missing go; then pkg_install go golang go go; fi
# Lint/format tools, pinned via go install (installs into
# Format tool, pinned via go install (installs into
# "$(go env GOPATH)/bin"; ensure that is on your PATH).
if missing golangci-lint; then go install "$GOLANGCI_LINT_REF"; fi
if missing goimports; then go install "$GOIMPORTS_REF"; fi
go mod download
+3 -1
View File
@@ -1,6 +1,8 @@
#!/bin/sh
# script/check: run all checks (test, lint, fmt-check). Our own
# extension to scripts-to-rule-them-all. Must not modify any files.
# extension to scripts-to-rule-them-all. test and lint are Docker
# phases; fmt-check is native, because a formatter writes the working
# tree. Must not modify any files.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
+19 -4
View File
@@ -1,13 +1,28 @@
#!/bin/sh
# script/cibuild: run the CI build. The Dockerfile runs make check, so
# a successful build implies all checks pass.
# script/cibuild: run the CI build. It bootstraps first: a CI runner
# checks out and runs this and nothing else, and script/fmt-check runs
# the formatter on the host, which a pristine checkout cannot do.
# --no-cache for the same reason as script/docker: the gate phases the
# final stage depends on are RUN steps, and a cached one is a check that
# did not run.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() {
cd "$ROOT"
docker build .
"$SCRIPT_DIR/bootstrap"
"$SCRIPT_DIR/check"
# Own line: a failing command substitution inside an argument does
# not trip `set -e`, so the inline form degrades silently to an
# empty constant. The VERSION build argument takes precedence over
# the version a build stage derives from the .git in the context.
version="$(git describe --tags --always --dirty 2>/dev/null || true)"
[ -n "$version" ] || version="unknown"
docker build --no-cache \
--build-arg VERSION="$version" \
-t "$("$SCRIPT_DIR/projectname")" .
}
main "$@"
+14 -3
View File
@@ -1,12 +1,23 @@
#!/bin/sh
# script/lint: run the linter.
# script/lint: run the linter. Linting is a phase of the Dockerfile and
# this builds that phase alone; the linter is never installed or run on
# a developer host, where a shared result cache and a host-global lock
# make its answer untrustworthy.
#
# The phase is not the last stage in the file, so it is built only when
# --target names it. --no-cache because a cached lint layer is a lint
# that did not run. The tag makes each build replace the previous image
# instead of leaving a dangling one behind.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() {
cd "$ROOT"
golangci-lint run --config .golangci.yml ./...
docker build --no-cache \
--target lint \
-t "$("$SCRIPT_DIR/projectname")-lint" .
}
main "$@"
+10 -3
View File
@@ -1,12 +1,19 @@
#!/bin/sh
# script/test: run the test suite.
# script/test: run the test suite. Testing is a phase of the Dockerfile
# and this builds that phase alone, on the same terms as script/lint:
# --target because a phase that is not the last stage is built only when
# named, --no-cache because a cached test layer is a test that did not
# run, and a tag so each build replaces the previous image.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() {
cd "$ROOT"
go test -v -race -timeout 30s -cover ./...
docker build --no-cache \
--target test \
-t "$("$SCRIPT_DIR/projectname")-test" .
}
main "$@"