Re-vendor the canonical files from sneak/prompts at c55a0cb (closes #95)
check / check (push) Canceled after 0s
check / check (push) Canceled after 0s
Every vendored file, REPO_POLICIES.md and every model script is the copy at sneak/prompts c55a0cb, with this repository's own entries kept after the canonical content. Lint and test are phases of the Dockerfile that write no image, built uncached. make test runs the suite under the race detector as nobody, because root reads the files the tests make unreadable. Dockerfile.lint, script/verify-lint-image-pin and make test-race are gone. Prettier runs on the host, from the node and yarn that script/bootstrap installs. golangci-lint v2.14.0 raises no findings. .claude/settings.json is deleted. Deviation: the set comes from c55a0cb on next rather than dd4027b, as the instructions on sneak/prompts#78 allow. Model: opus-5-5
This commit is contained in:
@@ -1,5 +0,0 @@
|
|||||||
{
|
|
||||||
"worktree": {
|
|
||||||
"bgIsolation": "none"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+73
-6
@@ -1,12 +1,79 @@
|
|||||||
|
# .dockerignore does NOT use .gitignore semantics. Docker matches with
|
||||||
|
# moby/patternmatcher: filepath.Match plus `**`, so `*` does not cross
|
||||||
|
# `/` and an unprefixed pattern is anchored at the context root. Every
|
||||||
|
# depth-independent pattern therefore needs `**/`, or `config/.env` and
|
||||||
|
# `certs/server.key` still ship while this file reads as solved. Only
|
||||||
|
# genuinely root-anchored entries go unprefixed. Never transplant these
|
||||||
|
# into .gitignore, where `**/` is wrong.
|
||||||
|
#
|
||||||
|
# Matching is case-sensitive, so secrets use character ranges rather
|
||||||
|
# than an ALL-CAPS twin, which would still miss `Server.Key`.
|
||||||
|
#
|
||||||
|
# Extend with this repo's own host-built artifacts, written anchored:
|
||||||
|
# `/myapp`, never `**/myapp`, which also matches `cmd/myapp/` and
|
||||||
|
# deletes the package directory from the context.
|
||||||
|
|
||||||
# .git is sent without its config. Without a VERSION build argument the
|
# .git is sent without its config. Without a VERSION build argument the
|
||||||
# stage that compiles runs `git describe --tags --always` on .git, which
|
# stage that compiles runs `git describe --tags --always` on .git, which
|
||||||
# does not need .git/config; that file can hold a credential, such as a
|
# does not need .git/config; that file can hold a credential, such as a
|
||||||
# password in a remote URL or the token the CI checkout step stores there.
|
# password in a remote URL or the token the CI checkout step stores there.
|
||||||
.git/config
|
# Each submodule keeps a config with the same exposure in its git directory
|
||||||
|
# under .git/modules/, nested again for a submodule's own submodules, or in
|
||||||
|
# its own .git directory when it keeps one.
|
||||||
|
# KNOWN GAP: a submodule whose name has a `config` segment (`config`,
|
||||||
|
# `deploy/config`, `config/lib`) loses its whole git directory, because
|
||||||
|
# `**/.git/modules/**/config` also matches that segment's directory
|
||||||
|
# under .git/modules/. Go's version stamping then fails the build;
|
||||||
|
# nothing leaks. Name such a submodule without that segment:
|
||||||
|
# `git submodule add --name`.
|
||||||
|
**/.git/config
|
||||||
|
**/.git/modules/**/config
|
||||||
|
|
||||||
|
# Agent scratch: one full checkout of the repo per in-flight agent.
|
||||||
|
# Anchored because it occurs once where agents run at the repo root.
|
||||||
|
# KNOWN GAP: a repo running agents in subdirectories still ships
|
||||||
|
# `services/api/.claude/` and must add its own anchored entry.
|
||||||
.claude
|
.claude
|
||||||
.DS_Store
|
|
||||||
sfdupes
|
# Environment files. `*.env` covers bare `.env` and the `prod.env`
|
||||||
*.log
|
# convention. Re-include a committed template with a negation if the
|
||||||
*.out
|
# build needs one: `!docs/example.env`.
|
||||||
*.test
|
**/*.[eE][nN][vV]
|
||||||
|
**/.[eE][nN][vV].*
|
||||||
|
**/.[eE][nN][vV][rR][cC]
|
||||||
|
|
||||||
|
# Private keys and the bundles carrying them. Public certificates
|
||||||
|
# (*.crt, *.cer) are deliberately absent: they are legitimate inputs.
|
||||||
|
**/*.[pP][eE][mM]
|
||||||
|
**/*.[kK][eE][yY]
|
||||||
|
**/*.[pP]12
|
||||||
|
**/*.[pP][fF][xX]
|
||||||
|
**/[iI][dD]_[rR][sS][aA]
|
||||||
|
**/[iI][dD]_[dD][sS][aA]
|
||||||
|
**/[iI][dD]_[eE][cC][dD][sS][aA]
|
||||||
|
**/[iI][dD]_[eE][cC][dD][sS][aA]_[sS][kK]
|
||||||
|
**/[iI][dD]_[eE][dD]25519
|
||||||
|
**/[iI][dD]_[eE][dD]25519_[sS][kK]
|
||||||
|
|
||||||
|
# Dependencies: restored inside the image, never copied in.
|
||||||
|
**/node_modules
|
||||||
|
|
||||||
|
# OS metadata.
|
||||||
|
**/.DS_Store
|
||||||
|
**/Thumbs.db
|
||||||
|
|
||||||
|
# Editor state: never a build input, and it churns COPY.
|
||||||
|
**/*.swp
|
||||||
|
**/*.swo
|
||||||
|
**/*~
|
||||||
|
**/*.bak
|
||||||
|
**/.idea
|
||||||
|
**/.vscode
|
||||||
|
**/*.sublime-*
|
||||||
|
|
||||||
|
# This repository's host-built artifacts: the binary `make build` writes,
|
||||||
|
# and test binaries, coverage output and logs.
|
||||||
|
/sfdupes
|
||||||
|
/*.test
|
||||||
|
/*.out
|
||||||
|
/*.log
|
||||||
|
|||||||
@@ -13,3 +13,6 @@ indent_style = tab
|
|||||||
|
|
||||||
[*.go]
|
[*.go]
|
||||||
indent_style = tab
|
indent_style = tab
|
||||||
|
|
||||||
|
# This repository's own sections, such as one for another language it
|
||||||
|
# uses, go below this comment, and a re-vendor keeps them.
|
||||||
|
|||||||
@@ -1,11 +1,20 @@
|
|||||||
name: check
|
name: check
|
||||||
on: [push]
|
on: [push]
|
||||||
|
# Free the shared runner: a new push cancels only the same branch's older run.
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
jobs:
|
jobs:
|
||||||
check:
|
check:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
# Free the shared runner from a hung build.
|
||||||
|
timeout-minutes: 20
|
||||||
steps:
|
steps:
|
||||||
# actions/checkout v4.2.2, 2026-02-22
|
# actions/checkout v4.2.2, 2026-02-22
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||||
|
# script/cibuild needs no token, so none is left in .git/config.
|
||||||
with:
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
# All history and tags, so git describe finds the version tag.
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- run: script/cibuild
|
- run: script/cibuild
|
||||||
|
|||||||
+37
-12
@@ -11,25 +11,50 @@ Thumbs.db
|
|||||||
.vscode/
|
.vscode/
|
||||||
*.sublime-*
|
*.sublime-*
|
||||||
|
|
||||||
|
# Agent scratch (worktrees of this repo, created and destroyed by
|
||||||
|
# in-flight tooling). Unanchored: .gitignore patterns already match at
|
||||||
|
# every depth, so no prefix is wanted here. This is not a .dockerignore
|
||||||
|
# entry and must not be given a `**/` prefix on the way into one.
|
||||||
|
.claude/
|
||||||
|
|
||||||
# Node
|
# Node
|
||||||
node_modules/
|
node_modules/
|
||||||
|
|
||||||
# Environment / secrets
|
# Secrets. Unanchored like every entry above, so each matches at every
|
||||||
.env
|
# depth. Matching is case-sensitive on Linux, so names use character
|
||||||
.env.*
|
# ranges rather than a lowercase form that misses `Server.Key`.
|
||||||
*.pem
|
|
||||||
*.key
|
|
||||||
|
|
||||||
# Go build artifacts
|
# Environment files. `*.env` covers bare `.env` and the `prod.env`
|
||||||
|
# convention. Only the templates `example.env` and `sample.env` are
|
||||||
|
# re-included below. A repository that commits any other template adds
|
||||||
|
# its own negation at the end of this file, for example `!.env.example`.
|
||||||
|
*.[eE][nN][vV]
|
||||||
|
.[eE][nN][vV].*
|
||||||
|
.[eE][nN][vV][rR][cC]
|
||||||
|
!example.env
|
||||||
|
!sample.env
|
||||||
|
|
||||||
|
# Private keys and the bundles carrying them.
|
||||||
|
*.[pP][eE][mM]
|
||||||
|
*.[kK][eE][yY]
|
||||||
|
*.[pP]12
|
||||||
|
*.[pP][fF][xX]
|
||||||
|
[iI][dD]_[rR][sS][aA]
|
||||||
|
[iI][dD]_[dD][sS][aA]
|
||||||
|
[iI][dD]_[eE][cC][dD][sS][aA]
|
||||||
|
[iI][dD]_[eE][cC][dD][sS][aA]_[sS][kK]
|
||||||
|
[iI][dD]_[eE][dD]25519
|
||||||
|
[iI][dD]_[eE][dD]25519_[sS][kK]
|
||||||
|
|
||||||
|
# This repository's own entries, such as its build outputs, go below
|
||||||
|
# this comment, and a re-vendor keeps them. Anchor a binary built at the
|
||||||
|
# root: `/myapp`, never `myapp`, which also ignores `cmd/myapp/`.
|
||||||
/sfdupes
|
/sfdupes
|
||||||
*.test
|
|
||||||
*.out
|
|
||||||
*.log
|
*.log
|
||||||
|
*.out
|
||||||
|
*.test
|
||||||
|
|
||||||
# Local scan data
|
# A scan database lists every path it scanned.
|
||||||
*.sqlite
|
*.sqlite
|
||||||
*.sqlite-shm
|
*.sqlite-shm
|
||||||
*.sqlite-wal
|
*.sqlite-wal
|
||||||
|
|
||||||
# Agent worktrees
|
|
||||||
.claude/worktrees/
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ linters:
|
|||||||
disable:
|
disable:
|
||||||
# Genuinely incompatible with project patterns
|
# Genuinely incompatible with project patterns
|
||||||
- exhaustruct # Requires all struct fields
|
- exhaustruct # Requires all struct fields
|
||||||
|
- exhaustruct_v5 # Requires all struct fields (successor to exhaustruct)
|
||||||
- godot # Requires comments to end with periods
|
- godot # Requires comments to end with periods
|
||||||
- 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
|
||||||
@@ -24,6 +25,8 @@ linters:
|
|||||||
# silenced by disabling that name, not by enabling the successor.
|
# silenced by disabling that name, not by enabling the successor.
|
||||||
- wsl # Deprecated, replaced by wsl_v5
|
- wsl # Deprecated, replaced by wsl_v5
|
||||||
- gomodguard # Deprecated, replaced by gomodguard_v2
|
- gomodguard # Deprecated, replaced by gomodguard_v2
|
||||||
|
# Misses findings at random in v2.14.0; back once a pinned release fixes it
|
||||||
|
- canonicalheader
|
||||||
settings:
|
settings:
|
||||||
lll:
|
lll:
|
||||||
line-length: 88
|
line-length: 88
|
||||||
|
|||||||
+43
-135
@@ -1,157 +1,64 @@
|
|||||||
# Lint stage — fast feedback on formatting and lint issues
|
# Lint phase, built alone by script/lint. The tools are invoked directly
|
||||||
# golangci/golangci-lint:v2.12.2, 2026-08-07
|
# rather than through `make lint`, which runs docker itself and so cannot
|
||||||
FROM golangci/golangci-lint@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS lint
|
# run inside a build step.
|
||||||
|
# golangci/golangci-lint:v2.14.0, 2026-10-07
|
||||||
|
FROM golangci/golangci-lint@sha256:ad862ba6b3798cbe0fd9fd7408d498fd74fbd2623a92406b2fd3898faf0bf98f AS lint
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Cache-buster for the gate layers, and only for them: on an unchanged
|
# The gofmt half of `make fmt-check`. gofmt's output is assigned to a
|
||||||
# tree Docker would serve the gates below from cache and the build would
|
# variable first so that its own exit status, as when it cannot parse a
|
||||||
# exit 0 having run nothing. script/cibuild and script/docker pass a
|
# file, still fails the step.
|
||||||
# fresh CHECK_EPOCH; a build without one, such as a bare
|
RUN files="$(gofmt -s -l .)" && \
|
||||||
# `docker build .`, fails at the check right after the ARG.
|
|
||||||
#
|
|
||||||
# ARG is per-stage, so the markdown and build stages declare it again.
|
|
||||||
# Each gate RUN must reference the value: BuildKit hashes the expanded
|
|
||||||
# command, so a declared but unreferenced ARG invalidates nothing. Keep
|
|
||||||
# it below the dependency layers so they stay cached.
|
|
||||||
ARG CHECK_EPOCH
|
|
||||||
RUN if [ -z "${CHECK_EPOCH}" ]; then \
|
|
||||||
echo "CHECK_EPOCH is unset; build via script/cibuild or script/docker" >&2; \
|
|
||||||
exit 1; \
|
|
||||||
fi
|
|
||||||
|
|
||||||
# These gates call the tools directly, not through `make lint` or
|
|
||||||
# `make fmt-check`: both run docker, which cannot run inside a docker
|
|
||||||
# build. This step is the gofmt half of `make fmt-check`; the markdown
|
|
||||||
# stage is its prettier half. gofmt's output is assigned to a variable
|
|
||||||
# first so that its own exit status, as when it cannot parse a file,
|
|
||||||
# still fails the step.
|
|
||||||
RUN echo "gate gofmt, epoch ${CHECK_EPOCH}" && \
|
|
||||||
files="$(gofmt -s -l .)" && \
|
|
||||||
if [ -n "$files" ]; then \
|
if [ -n "$files" ]; then \
|
||||||
echo "gofmt: files not formatted:" >&2; echo "$files" >&2; exit 1; \
|
echo "gofmt: files not formatted:" >&2; echo "$files" >&2; exit 1; \
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Fails the build when the FROM above and the one in Dockerfile.lint pin
|
# Validates .golangci.yml against the schema the pinned binary embeds.
|
||||||
# different linter images.
|
RUN golangci-lint config verify --config .golangci.yml
|
||||||
RUN echo "gate lint-image-pin, epoch ${CHECK_EPOCH}" && \
|
RUN golangci-lint run --config .golangci.yml ./...
|
||||||
script/verify-lint-image-pin
|
|
||||||
|
|
||||||
# Same config-schema check Dockerfile.lint runs, kept here so this build
|
# Test phase, built alone by script/test. -race needs cgo and so a C
|
||||||
# gates on exactly what script/lint gates on. It validates against a
|
# compiler, which the Debian Go image ships and the alpine one does not.
|
||||||
# schema the pinned binary embeds, so it needs no network.
|
#
|
||||||
RUN echo "gate config verify, epoch ${CHECK_EPOCH}" && \
|
# The tests run as nobody: several of them make a file unreadable and
|
||||||
golangci-lint config verify --config .golangci.yml
|
# expect reading it to fail, and root reads it anyway. nobody has no home
|
||||||
|
# directory, so HOME is /tmp, where Go puts its build cache.
|
||||||
RUN echo "gate lint, epoch ${CHECK_EPOCH}" && \
|
# golang:1.25-trixie, 2026-10-04
|
||||||
golangci-lint run --config .golangci.yml ./...
|
FROM golang@sha256:2c4c60ef415fbfa5e90300722293bef36c5e63fae17570ce18f580af933dbd73 AS test
|
||||||
|
USER nobody
|
||||||
# Prettier stage: the prettier that formats this repository's Markdown,
|
ENV HOME=/tmp
|
||||||
# never installed on a host. script/fmt and script/fmt-check build this
|
|
||||||
# stage alone and run it with the repository mounted on /src. prettier
|
|
||||||
# is installed in /tools so that the repository, mounted or copied onto
|
|
||||||
# /src, cannot hide it.
|
|
||||||
# node:22-alpine, 2026-02-22
|
|
||||||
FROM node@sha256:e4bf2a82ad0a4037d28035ae71529873c069b13eb0455466ae0bc13363826e34 AS prettier
|
|
||||||
WORKDIR /tools
|
|
||||||
# yarn.lock pins prettier by hash, and --frozen-lockfile fails rather
|
|
||||||
# than install anything yarn.lock does not name.
|
|
||||||
COPY package.json yarn.lock ./
|
|
||||||
RUN yarn install --frozen-lockfile
|
|
||||||
ENV PATH=/tools/node_modules/.bin:$PATH
|
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
|
COPY go.mod go.sum ./
|
||||||
# Markdown stage: the Markdown half of `make fmt-check`, as a gate.
|
RUN go mod download
|
||||||
FROM prettier AS markdown
|
|
||||||
COPY . .
|
COPY . .
|
||||||
# Second per-stage declaration of the gate cache-buster and its check;
|
RUN go test -timeout 90s -race -cover ./... || \
|
||||||
# see the lint stage above.
|
{ echo "--- Rerunning with -v for details ---"; \
|
||||||
ARG CHECK_EPOCH
|
go test -timeout 90s -race -v ./...; exit 1; }
|
||||||
RUN if [ -z "${CHECK_EPOCH}" ]; then \
|
|
||||||
echo "CHECK_EPOCH is unset; build via script/cibuild or script/docker" >&2; \
|
|
||||||
exit 1; \
|
|
||||||
fi
|
|
||||||
RUN echo "gate prettier, epoch ${CHECK_EPOCH}" && \
|
|
||||||
prettier --check '**/*.md' --tab-width 4 --prose-wrap always
|
|
||||||
|
|
||||||
# Build stage
|
# 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-07-23
|
# golang:1.25-alpine, 2026-07-23
|
||||||
FROM golang@sha256:56961d79ea8129efddcc0b8643fd8a5416b4e6228cfd477e3fd61deb2672c587 AS builder
|
FROM golang@sha256:56961d79ea8129efddcc0b8643fd8a5416b4e6228cfd477e3fd61deb2672c587 AS builder
|
||||||
|
|
||||||
# We never build or run as root. Create an unprivileged user and point
|
|
||||||
# HOME and the build cache at its home so go build and go test can write
|
|
||||||
# it when we drop to it below.
|
|
||||||
#
|
|
||||||
# The module cache stays at the base image's default /go/pkg/mod and
|
|
||||||
# belongs to root: script/bootstrap fills it as root. Do not move it
|
|
||||||
# into the home and hand it over with `chown -R`: that walks every file
|
|
||||||
# in it, which took from about 80 s to over ten minutes on a shared
|
|
||||||
# host, depending on load.
|
|
||||||
RUN adduser -D -u 1000 builder
|
|
||||||
ENV HOME=/home/builder
|
|
||||||
ENV GOPATH=/home/builder/go
|
|
||||||
ENV GOMODCACHE=/go/pkg/mod
|
|
||||||
ENV GOCACHE=/home/builder/.cache/go-build
|
|
||||||
|
|
||||||
WORKDIR /src
|
|
||||||
|
|
||||||
# No-op file copies whose only purpose is the build-graph edge: they
|
|
||||||
# make this stage depend on the lint and markdown stages, so BuildKit
|
|
||||||
# finishes those gates before compilation and tests start. Remove one
|
|
||||||
# and the build silently stops gating on that stage and still exits 0.
|
|
||||||
COPY --from=lint /src/go.sum /dev/null
|
COPY --from=lint /src/go.sum /dev/null
|
||||||
COPY --from=markdown /src/go.sum /dev/null
|
COPY --from=test /src/go.sum /dev/null
|
||||||
|
RUN apk add --no-cache git make
|
||||||
# Install development prerequisites the same way a developer does. Only
|
# A tar-stream context keeps the sender's file owners, which git refuses.
|
||||||
# script/ and the dependency manifests are copied first, so this layer
|
RUN git config --system --add safe.directory /src
|
||||||
# stays cached until they change. Bootstrap ends in `go mod download`.
|
WORKDIR /src
|
||||||
COPY script/ script/
|
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN script/bootstrap
|
RUN go mod download
|
||||||
|
COPY . .
|
||||||
# Hand builder only what it writes to, without walking the module cache.
|
|
||||||
# This layer stays cached with bootstrap.
|
|
||||||
# - /src itself: make build writes the binary into it, and git refuses
|
|
||||||
# a repository whose top directory belongs to another user.
|
|
||||||
# - the module cache's cache/download directory itself, not what is in
|
|
||||||
# it: Go only reads the downloaded modules, but make build saves its
|
|
||||||
# lookup of this module's own version from git there, in a new
|
|
||||||
# directory named after the module path.
|
|
||||||
# - builder's home: the go commands bootstrap ran as root left Go's
|
|
||||||
# telemetry files there, a few small files.
|
|
||||||
RUN chown builder:builder /src /go/pkg/mod/cache/download && \
|
|
||||||
chown -R builder:builder /home/builder
|
|
||||||
|
|
||||||
# The sources are handed to builder as they are copied, so no layer has
|
|
||||||
# to walk them. Then drop root before running any checks or builds.
|
|
||||||
COPY --chown=builder:builder . .
|
|
||||||
USER builder
|
|
||||||
|
|
||||||
# Fail the build unless the branch is green. Runs as non-root: root
|
|
||||||
# would bypass the chmod(0) the permission-denied tests rely on.
|
|
||||||
#
|
|
||||||
# The gate is `make test`, not `make check`, which runs docker; lint and
|
|
||||||
# the format checks ran in the lint and markdown stages above. `make`,
|
|
||||||
# not the script directly, because the Makefile's
|
|
||||||
# `export CGO_ENABLED = 0` applies only to what it invokes.
|
|
||||||
#
|
|
||||||
# Third per-stage declaration of the gate cache-buster and its check;
|
|
||||||
# see the lint stage above. It is placed after USER so the drop to the
|
|
||||||
# unprivileged user still happens before the checks run.
|
|
||||||
ARG CHECK_EPOCH
|
|
||||||
RUN if [ -z "${CHECK_EPOCH}" ]; then \
|
|
||||||
echo "CHECK_EPOCH is unset; build via script/cibuild or script/docker" >&2; \
|
|
||||||
exit 1; \
|
|
||||||
fi
|
|
||||||
RUN echo "gate test, epoch ${CHECK_EPOCH}" && make test
|
|
||||||
|
|
||||||
# The version stamped into the binary: the VERSION build argument when
|
# The version stamped into the binary: the VERSION build argument when
|
||||||
# one is given, otherwise `git describe --tags --always` of the .git in
|
# one is given, otherwise `git describe --tags --always` of the .git in
|
||||||
# the build context (git is installed by script/bootstrap above). A
|
# the build context. A context that carries .git and still yields no
|
||||||
# context that carries .git and still yields no version fails the build;
|
# version fails the build; with neither, as from a source tarball, it is
|
||||||
# with neither, as from a source tarball, it is "dev".
|
# "dev". `make build` rather than `go build`, so the image and a host
|
||||||
|
# build share one compile recipe, cgo disabled included.
|
||||||
ARG VERSION
|
ARG VERSION
|
||||||
RUN version="${VERSION:-$(git describe --tags --always || echo dev)}"; \
|
RUN version="${VERSION:-$(git describe --tags --always || echo dev)}"; \
|
||||||
if [ -e .git ] && { [ -z "$version" ] || [ "$version" = dev ] || \
|
if [ -e .git ] && { [ -z "$version" ] || [ "$version" = dev ] || \
|
||||||
@@ -161,7 +68,8 @@ RUN version="${VERSION:-$(git describe --tags --always || echo dev)}"; \
|
|||||||
fi; \
|
fi; \
|
||||||
make build VERSION="$version"
|
make build VERSION="$version"
|
||||||
|
|
||||||
# Runtime stage
|
# Runtime stage, and the last one: a plain `docker build .` builds this
|
||||||
|
# stage's chain and nothing else.
|
||||||
# alpine:3.22, 2026-07-23
|
# alpine:3.22, 2026-07-23
|
||||||
FROM alpine@sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695dce
|
FROM alpine@sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695dce
|
||||||
|
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
# Lint-only image, built by script/lint: the repo is copied into the
|
|
||||||
# pinned golangci-lint image and the linter runs as a build step, so a
|
|
||||||
# successful build is a clean lint. No bind mount, so it works when the
|
|
||||||
# docker daemon is remote.
|
|
||||||
#
|
|
||||||
# It is separate from the main Dockerfile's lint stage because
|
|
||||||
# script/lint must not depend on the rest of that build; the two FROM
|
|
||||||
# lines are kept identical by script/verify-lint-image-pin, run as a
|
|
||||||
# gate below.
|
|
||||||
# golangci/golangci-lint:v2.12.2, 2026-08-07
|
|
||||||
FROM golangci/golangci-lint@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240
|
|
||||||
|
|
||||||
WORKDIR /src
|
|
||||||
|
|
||||||
# Dependency layers first, so they stay cached across lint runs.
|
|
||||||
COPY go.mod go.sum ./
|
|
||||||
RUN go mod download
|
|
||||||
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
# Cache-buster for the gate layers, and only for them; caching of the
|
|
||||||
# lint run is waived by ruling. On an unchanged tree the gates below
|
|
||||||
# would be served from cache and this build would exit 0 in under a
|
|
||||||
# second having run no linter. script/lint passes a fresh value on every
|
|
||||||
# invocation.
|
|
||||||
#
|
|
||||||
# Each gate RUN must reference the value: BuildKit hashes the expanded
|
|
||||||
# command, so a declared but unreferenced ARG invalidates nothing. Keep
|
|
||||||
# it below the dependency layers so they stay cached.
|
|
||||||
ARG CHECK_EPOCH
|
|
||||||
|
|
||||||
# Fails the build when the FROM above and the main Dockerfile's lint
|
|
||||||
# stage pin different linter images.
|
|
||||||
RUN echo "gate lint-image-pin, epoch ${CHECK_EPOCH}" && \
|
|
||||||
script/verify-lint-image-pin
|
|
||||||
|
|
||||||
# Validates .golangci.yml against golangci-lint's JSON schema, which the
|
|
||||||
# pinned binary embeds: measured under `--network none`, it passes a
|
|
||||||
# valid config and rejects an invalid one. No gate step makes a network
|
|
||||||
# call, but `go mod download` above needs the network on a cold cache.
|
|
||||||
RUN echo "gate config verify, epoch ${CHECK_EPOCH}" && \
|
|
||||||
golangci-lint config verify --config .golangci.yml
|
|
||||||
|
|
||||||
RUN echo "gate lint, epoch ${CHECK_EPOCH}" && \
|
|
||||||
golangci-lint run --config .golangci.yml ./...
|
|
||||||
@@ -6,7 +6,7 @@ BINARY := sfdupes
|
|||||||
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
|
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
|
||||||
LDFLAGS := -X main.Version=$(VERSION)
|
LDFLAGS := -X main.Version=$(VERSION)
|
||||||
|
|
||||||
.PHONY: sfdupes build bootstrap setup test test-race lint fmt fmt-check check docker hooks clean
|
.PHONY: sfdupes build bootstrap setup test lint fmt fmt-check check docker hooks clean
|
||||||
|
|
||||||
# Standard targets are thin shims; the implementations live in script/
|
# Standard targets are thin shims; the implementations live in script/
|
||||||
# per the scripts-to-rule-them-all pattern.
|
# per the scripts-to-rule-them-all pattern.
|
||||||
@@ -27,9 +27,6 @@ setup:
|
|||||||
test:
|
test:
|
||||||
@script/test
|
@script/test
|
||||||
|
|
||||||
test-race:
|
|
||||||
@script/test-race
|
|
||||||
|
|
||||||
lint:
|
lint:
|
||||||
@script/lint
|
@script/lint
|
||||||
|
|
||||||
|
|||||||
@@ -746,104 +746,62 @@ entrypoints are:
|
|||||||
- `script/bootstrap` — install everything needed to build and develop this
|
- `script/bootstrap` — install everything needed to build and develop this
|
||||||
repository, idempotently, assuming nothing is present. `git`, `make`, and `go`
|
repository, idempotently, assuming nothing is present. `git`, `make`, and `go`
|
||||||
come from the first of nix, apt, brew, or apk found on the host, and are
|
come from the first of nix, apt, brew, or apk found on the host, and are
|
||||||
presence-checked only. `golangci-lint` and prettier are deliberately **not**
|
presence-checked only. An installed node is used as it is; otherwise node
|
||||||
installed: they run in Docker (see `script/lint` and `script/fmt`) and never
|
22.17.0 is installed through nvm, which comes from a release archive whose
|
||||||
from a host install, so there is no host copy to drift from the pin. A missing
|
sha256 the script checks. A yarn already on `PATH` is used as it is; otherwise
|
||||||
`docker` is warned about rather than installed or treated as fatal —
|
yarn 1.22.22 is activated through corepack, or installed with `npm` when there
|
||||||
everything except linting, formatting and `make test-race` works without it.
|
is no corepack. `yarn install --frozen-lockfile` then installs the prettier
|
||||||
Ends with `go mod download`.
|
that `package.json` and `yarn.lock` pin. `golangci-lint` is never installed:
|
||||||
|
it runs in Docker (see `script/lint`). `docker` is not installed either;
|
||||||
|
testing, linting and the image build need it. Ends with `go mod download`.
|
||||||
- `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` — print this project's name (`sfdupes`). Scripts that
|
- `script/projectname` — print this project's name (`sfdupes`). Scripts that
|
||||||
need the name call it, so they stay identical across repositories.
|
need the name call it, so they stay identical across repositories.
|
||||||
- `script/test` — run the test suite with a 30-second timeout and coverage
|
- `script/test` — run the test suite under the race detector, with a 90-second
|
||||||
enabled, rerunning verbosely on failure so the logs show which test failed.
|
timeout and coverage enabled, rerunning verbosely on failure so the logs show
|
||||||
- `script/test-race` — run the test suite under the race detector with a
|
which test failed. It builds the `Dockerfile`'s `test` phase alone and writes
|
||||||
60-second timeout. The detector needs cgo and a C compiler, which the build
|
no image. The race detector needs cgo and a C compiler, which the build never
|
||||||
never uses, so the tests run in a digest-pinned Debian `golang` image that has
|
uses, so the phase starts from a digest-pinned Debian `golang` image, which
|
||||||
`gcc`, with the checkout mounted read-only; the container is removed when it
|
has `gcc`. The tests run as `nobody`, because several of them make a file
|
||||||
exits. They run as the calling user, or as `nobody` when that is root, because
|
unreadable and root reads it anyway.
|
||||||
several tests make a file unreadable and root reads it anyway; only then must
|
- `script/lint` — run the linter. It builds the `Dockerfile`'s `lint` phase
|
||||||
the checkout be readable by other users. Not part of `script/check`. Every run
|
alone and writes no image: in the digest-pinned `golangci/golangci-lint`
|
||||||
starts with empty caches, so it needs the network and takes minutes, and the
|
image, the gofmt check, `golangci-lint config verify` and `golangci-lint run`
|
||||||
mount needs a local docker daemon.
|
run as build steps, so a successful build is a clean lint. The linter is never
|
||||||
- `script/lint` — run the linter. It builds `Dockerfile.lint`, which copies the
|
run on the host, which makes a working `docker` the one prerequisite for
|
||||||
repository into the digest-pinned `golangci/golangci-lint` image and runs
|
linting.
|
||||||
`golangci-lint config verify` and `golangci-lint run` as build steps, so a
|
|
||||||
successful build is a clean lint. That exit status is all it produces, so it
|
|
||||||
runs with `--output=type=cacheonly` and writes no image; a run leaves only
|
|
||||||
build cache. The linter is never run on the host, which makes a working
|
|
||||||
`docker` the one prerequisite for linting — and therefore for `make check` and
|
|
||||||
the pre-commit hook. Offline machines: the gate steps themselves make no
|
|
||||||
network calls. `golangci-lint run` does not, and neither does
|
|
||||||
`golangci-lint config verify` — it validates against a schema the pinned
|
|
||||||
binary embeds, measured under `--network none` to both pass a valid config and
|
|
||||||
reject an invalid one. The build around them does. `Dockerfile.lint` runs
|
|
||||||
`go mod download` before the gates and this module has external dependencies,
|
|
||||||
so a first lint on a machine with a cold BuildKit cache reaches the network
|
|
||||||
there (as well as pulling the pinned image); under `--network none` it fails
|
|
||||||
at that step, before any gate. That layer sits above the gates and stays
|
|
||||||
cached, so once it is warm `script/lint` — and with it `make check` — runs
|
|
||||||
entirely offline, until `go.mod` or `go.sum` changes and the download layer
|
|
||||||
goes cold again. Because the daemon only ever sees a build context, this works
|
|
||||||
when the docker daemon is remote and bind mounts are impossible.
|
|
||||||
- `script/fmt` — format in place: the Go sources with `gofmt -s -w`, and every
|
- `script/fmt` — format in place: the Go sources with `gofmt -s -w`, and every
|
||||||
Markdown file with prettier, at the settings in `.prettierrc` (4-space
|
Markdown file with prettier, at the settings in `.prettierrc` (4-space
|
||||||
indents, prose wrapped at 80 columns). prettier is pinned by hash through
|
indents, prose wrapped at 80 columns). Both run on the host, prettier through
|
||||||
`package.json` and `yarn.lock` and never installed on the host: this builds
|
yarn at the version `yarn.lock` pins. When yarn is not on `PATH`, this loads
|
||||||
the `Dockerfile`'s `prettier` stage, a digest-pinned node image into which
|
the node 22.17.0 that `script/bootstrap` installed through nvm.
|
||||||
`yarn install --frozen-lockfile` installs it, tagged `sfdupes-prettier`, and
|
- `script/fmt-check` — the read-only counterpart of `script/fmt`: prints any
|
||||||
runs that with the repository mounted, as the calling user. Needs `docker`,
|
unformatted file and exits non-zero instead of writing. gofmt and prettier
|
||||||
and because of the mount, unlike `script/lint`, a local docker daemon.
|
both run every time, and each names itself when it fails. The `Dockerfile`'s
|
||||||
- `script/fmt-check` — the read-only counterpart of `script/fmt`, with the
|
`lint` phase runs the same gofmt check.
|
||||||
repository mounted read-only: prints any unformatted file and exits non-zero
|
|
||||||
instead of writing. gofmt and prettier both run every time, and each names
|
|
||||||
itself when it fails. The `Dockerfile` runs the same two checks as gates: the
|
|
||||||
gofmt check in its lint stage, prettier in its `markdown` stage.
|
|
||||||
- `script/check` — run `script/test`, `script/lint`, and `script/fmt-check`, in
|
- `script/check` — run `script/test`, `script/lint`, and `script/fmt-check`, in
|
||||||
that order. Modifies nothing. Needs `docker`, because `script/lint` and
|
that order. Modifies nothing. The first two need `docker`, the third what
|
||||||
`script/fmt-check` do.
|
`script/bootstrap` installs.
|
||||||
- `script/docker` — build the Docker image, tagged with the name from
|
- `script/docker` — build the Docker image, tagged with the name from
|
||||||
`script/projectname`. The `Dockerfile` runs the gates as build steps, so this
|
`script/projectname`, passing the output of
|
||||||
is also the check a developer or reviewer runs by hand.
|
`git describe --tags --always --dirty` (or `unknown` when that is empty) as
|
||||||
- `script/cibuild` — build the Docker image untagged. This is what the Gitea
|
the `VERSION` build argument. The `Dockerfile`'s build stage depends on its
|
||||||
workflow runs on push; because the gates run as build steps, a successful
|
`lint` and `test` phases, so this also runs the tests and the linter.
|
||||||
build implies the repository is green.
|
- `script/cibuild` — run `script/bootstrap`, then `script/check`, then build the
|
||||||
|
image as `script/docker` does. This is what the Gitea workflow runs on push; a
|
||||||
|
successful run means every check passed. The tests and the linter run twice:
|
||||||
|
once in `script/check`, and again as stages of the image build.
|
||||||
- `script/precommit` — run by the git pre-commit hook: `go mod tidy` must be a
|
- `script/precommit` — run by the git pre-commit hook: `go mod tidy` must be a
|
||||||
no-op (a resulting change to `go.mod` or `go.sum` fails the commit), then
|
no-op (a resulting change to `go.mod` or `go.sum` fails the commit), then
|
||||||
`script/check`.
|
`script/check`.
|
||||||
- `script/install-precommit` — install the git pre-commit hook that runs
|
- `script/install-precommit` — install the git pre-commit hook, as
|
||||||
`script/precommit`. The hook is written to the common git directory, so the
|
`.git/hooks/pre-commit`, that runs `script/precommit`.
|
||||||
main checkout and every worktree share it.
|
|
||||||
- `script/verify-lint-image-pin` — fail unless the `golangci/golangci-lint`
|
|
||||||
reference in `Dockerfile.lint` and the one in the `Dockerfile` lint stage are
|
|
||||||
the same image at the same digest, naming both if not. The linter is pinned in
|
|
||||||
those two files and nothing else keeps them in sync, so a bump applied to one
|
|
||||||
alone would leave `make lint` and the `Dockerfile`'s fail-fast lint stage
|
|
||||||
checking the same tree against different rulesets, both green. The guard
|
|
||||||
restates neither pin — a third copy would be the same drift one file further
|
|
||||||
out — and runs as a gate in both files, so `make lint`, `make check` and
|
|
||||||
`make docker` all catch it.
|
|
||||||
|
|
||||||
`script/verify-linter-pin` used to live here. It compared a linter binary
|
Every `docker build` in `script/` passes `--no-cache`. On an unchanged tree
|
||||||
against a version pin in `script/bootstrap`, and both of its subjects are gone:
|
Docker would serve the gate steps from its cache, and the build would pass
|
||||||
no linter binary is copied between build stages any more, and bootstrap pins no
|
having run no test and no linter. Every run therefore downloads the Go modules
|
||||||
version because it installs no linter. The drift it existed to catch has moved
|
again and needs the network.
|
||||||
from binary-versus-pin to pin-versus-pin, which is what
|
|
||||||
`script/verify-lint-image-pin` above checks.
|
|
||||||
|
|
||||||
`script/lint`, `script/docker` and `script/cibuild` all pass a freshly computed
|
|
||||||
`CHECK_EPOCH` build argument, and the gate steps in `Dockerfile.lint` and
|
|
||||||
`Dockerfile` reference it. Without that, an unchanged tree lets Docker serve the
|
|
||||||
gate layers from cache and the build exits 0 having executed no tests and no
|
|
||||||
lint — a green it never earned, and one this repository has produced twice.
|
|
||||||
`CHECK_EPOCH` invalidates the gate layers on every run while leaving the pinned
|
|
||||||
base images and the dependency layers cached. Each script's value carries the
|
|
||||||
process id as well as the epoch, because two runs land inside the same second
|
|
||||||
easily and a bare epoch would cache the second one. Each `Dockerfile` stage with
|
|
||||||
gates fails when the value is empty, so a bare `docker build .` stops with
|
|
||||||
`CHECK_EPOCH is unset; build via script/cibuild or script/docker` instead of
|
|
||||||
serving the gates from cache.
|
|
||||||
|
|
||||||
## Build
|
## Build
|
||||||
|
|
||||||
@@ -855,17 +813,15 @@ compile recipe:
|
|||||||
the default target.
|
the default target.
|
||||||
- `make bootstrap` — install the build and development dependencies.
|
- `make bootstrap` — install the build and development dependencies.
|
||||||
- `make setup` — prepare a fresh clone: `bootstrap` plus the pre-commit hook.
|
- `make setup` — prepare a fresh clone: `bootstrap` plus the pre-commit hook.
|
||||||
- `make test` — run the test suite (30-second timeout; reruns with `-v` on
|
- `make test` — run the test suite under the race detector, in Docker (90-second
|
||||||
failure).
|
timeout; reruns with `-v` on failure; see `script/test`); requires `docker`.
|
||||||
- `make test-race` — run the test suite under the race detector, in Docker (see
|
- `make lint` — run `golangci-lint` with the repo config and the gofmt check, in
|
||||||
`script/test-race`); requires `docker`. Not part of `make check`.
|
Docker (see `script/lint`); requires `docker`.
|
||||||
- `make lint` — run `golangci-lint` with the repo config, in Docker (see
|
|
||||||
`script/lint`); requires `docker`.
|
|
||||||
- `make fmt` / `make fmt-check` — format the Go sources and the Markdown /
|
- `make fmt` / `make fmt-check` — format the Go sources and the Markdown /
|
||||||
verify formatting without writing; requires `docker`, for prettier (see
|
verify formatting without writing, on the host; requires `go` and what
|
||||||
`script/fmt`).
|
`make bootstrap` installs (see `script/fmt`).
|
||||||
- `make check` — `test`, `lint`, and `fmt-check`; modifies nothing. Requires
|
- `make check` — `test`, `lint`, and `fmt-check`; modifies nothing. Requires
|
||||||
`docker`, via `lint` and `fmt-check`.
|
`docker` and what `make bootstrap` installs.
|
||||||
- `make docker` — build the Docker image, which runs the gates as build stages.
|
- `make docker` — build the Docker image, which runs the gates as build stages.
|
||||||
- `make hooks` — install the pre-commit hook.
|
- `make hooks` — install the pre-commit hook.
|
||||||
- `make clean` — remove the binary.
|
- `make clean` — remove the binary.
|
||||||
|
|||||||
+390
-86
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
title: Repository Policies
|
title: Repository Policies
|
||||||
last_modified: 2026-07-06
|
last_modified: 2026-10-07
|
||||||
---
|
---
|
||||||
|
|
||||||
This document covers repository structure, tooling, and workflow standards. Code
|
This document covers repository structure, tooling, and workflow standards. Code
|
||||||
@@ -60,17 +60,28 @@ style conventions are in separate documents:
|
|||||||
prerequisite since nvm requires bash. yarn is then pinned via
|
prerequisite since nvm requires bash. yarn is then pinned via
|
||||||
`corepack prepare yarn@<version> --activate`. Never install "latest" or "lts";
|
`corepack prepare yarn@<version> --activate`. Never install "latest" or "lts";
|
||||||
always exact versions. `script/cibuild` runs the CI build: it changes to the
|
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
|
repo root, runs `script/bootstrap`, runs `script/check`, and builds the image
|
||||||
scripts are our own extensions to the standard: `script/check` runs
|
with the version; the Gitea workflow calls it. **`script/cibuild` runs
|
||||||
`script/test`, `script/lint`, and `script/fmt-check`; `script/precommit` is
|
`script/bootstrap` first**, because the workflow checks out the repo and runs
|
||||||
what the git pre-commit hook runs, and it calls `script/check`;
|
nothing else, while `script/fmt-check` runs the formatter on the host: on a
|
||||||
`script/install-precommit` installs the git pre-commit hook (the `make hooks`
|
pristine checkout with nothing installed the run dies there, after the
|
||||||
target shims to it); and `script/projectname` (literally that filename) simply
|
containerised gates have passed. **The bootstrap alone is not enough**:
|
||||||
outputs the project's name. Scripts that need the name call
|
`script/bootstrap` installs node and yarn under nvm and leaves neither on the
|
||||||
`script/projectname` — e.g. `script/docker` assembles its image tag from it —
|
`PATH` of the shell that called it, so a bare `yarn` still exits 127. The host
|
||||||
so those scripts stay byte-identical across all repos. Repo-type-specific
|
entrypoints that need yarn — `script/fmt` and `script/fmt-check` — therefore
|
||||||
pre-commit extras (e.g. `go mod tidy` verification in Go repos) belong in
|
source nvm for the pinned node version before invoking it, exactly as
|
||||||
`script/precommit`, not in the hook itself. Model scripts are at
|
`script/bootstrap`'s own install step does. A runner carrying nothing but
|
||||||
|
docker and git then gets through `script/check`. 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
|
`https://git.eeqj.de/sneak/prompts/raw/branch/main/script/<name>`. The README
|
||||||
must document the provided scripts in an **Entrypoints** section (see the
|
must document the provided scripts in an **Entrypoints** section (see the
|
||||||
README requirements below).
|
README requirements below).
|
||||||
@@ -89,87 +100,222 @@ style conventions are in separate documents:
|
|||||||
contributor should be able to understand the entire development workflow by
|
contributor should be able to understand the entire development workflow by
|
||||||
reading the Makefile.
|
reading the Makefile.
|
||||||
|
|
||||||
- Every repo should have a `Dockerfile`. All Dockerfiles must run `make check`
|
- Every repo should have a `Dockerfile`, and it carries the repo's gates: a
|
||||||
as a build step so the build fails if the branch is not green. For non-server
|
`lint` phase and a `test` phase, with the final stage depending on both so the
|
||||||
repos, the Dockerfile should bring up a development environment and run
|
image cannot be built unless they pass. For non-server repos the final stage
|
||||||
`make check`. For server repos, `make check` should run as an early build
|
brings up a development environment; for server repos it is the runtime image.
|
||||||
stage before the final image is assembled. Dockerfiles install development
|
The gate phases and the build stage start from their pinned base images and
|
||||||
prerequisites by running `script/bootstrap` rather than duplicating installs
|
install what those images lack either inline, as the canonical Go `Dockerfile`
|
||||||
inline; COPY `script/` and the dependency manifests (`package.json` +
|
below does for `git`, or by running `script/bootstrap`, as the `prompts`
|
||||||
`yarn.lock`, `go.mod` + `go.sum`, etc.) before running it so the bootstrap
|
repo's own `Dockerfile` does for its yarn packages. The development
|
||||||
layer stays cached until dependencies change.
|
environment stage installs development prerequisites by running
|
||||||
|
`script/bootstrap` rather than duplicating its installs inline. A stage that
|
||||||
|
runs `script/bootstrap` COPYs `script/` and the dependency manifests
|
||||||
|
(`package.json` + `yarn.lock`, `go.mod` + `go.sum`, etc.) before running it.
|
||||||
|
|
||||||
- **Dockerfiles must use a separate lint stage for fail-fast feedback.** Go
|
- **Linting and testing run in Docker, as phases of the `Dockerfile`.** There is
|
||||||
repos use a multistage build where linting runs in an independent stage based
|
no separate lint file. `script/lint` and `script/test` each build one phase
|
||||||
on the `golangci/golangci-lint` image (pinned by hash). This stage runs
|
and nothing else:
|
||||||
`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:
|
```sh
|
||||||
|
docker build --no-cache --target lint --output type=cacheonly .
|
||||||
|
docker build --no-cache --target test --output type=cacheonly .
|
||||||
|
```
|
||||||
|
|
||||||
|
**A stage that is not the last one in the file is built only when the final
|
||||||
|
stage's chain depends on it, or when `--target` names it.** That is why the
|
||||||
|
two gates are always invoked by name here, and why the final stage carries a
|
||||||
|
`COPY --from=` of a harmless file from each of them: without that edge a
|
||||||
|
plain `docker build .` builds the last stage alone and exits 0 having linted
|
||||||
|
and tested nothing.
|
||||||
|
|
||||||
|
**The gate builds write no image.** With `--output type=cacheonly` the phase
|
||||||
|
runs and a failing step fails the build, but the result is not exported.
|
||||||
|
Nothing uses those images, and writing one out is slow: a Go test phase's
|
||||||
|
image holds the toolchain and every compiled package. A build given neither
|
||||||
|
`--output` nor `-t` writes an untagged image and leaves it dangling, on
|
||||||
|
every developer host and every CI runner. `script/cibuild` and
|
||||||
|
`script/docker` build the image that ships and tag it, so each build
|
||||||
|
replaces the previous image; each assigns the tag on its own line before the
|
||||||
|
build, so `set -e` stops it where `script/projectname` fails.
|
||||||
|
|
||||||
|
Inside a phase the tool is invoked directly — `golangci-lint`, `go test`,
|
||||||
|
`eslint`, `prettier` — never through `make lint` or `script/test`, which are
|
||||||
|
themselves a `docker build` and would recurse into a daemon that does not
|
||||||
|
exist in a build step. Formatting is the exception and stays on the host:
|
||||||
|
`script/fmt` writes the working tree, and `script/fmt-check` is its
|
||||||
|
read-only twin.
|
||||||
|
|
||||||
|
**No lint verdict may come from a host invocation of the linter.** On a
|
||||||
|
shared host golangci-lint reads a result cache keyed on file content rather
|
||||||
|
than location, so a second checkout of the same content is served the first
|
||||||
|
one's findings, and a host-global lock in `$TMPDIR` makes concurrent runs
|
||||||
|
exit non-zero with `parallel golangci-lint is running` — a status a caller
|
||||||
|
cannot tell from real findings. Both have produced wrong verdicts in this
|
||||||
|
org, in both directions. A container has its own cache, its own `TMPDIR` and
|
||||||
|
a digest-pinned binary, so neither is reachable.
|
||||||
|
|
||||||
|
- **Any build that runs checks is built with `--no-cache`.** Docker invalidates
|
||||||
|
a `COPY` layer only when the copied content changes, so on an unchanged tree
|
||||||
|
the check `RUN` is served from cache, nothing executes, and the build still
|
||||||
|
exits 0. Every `docker build` in `script/` therefore passes `--no-cache`:
|
||||||
|
`script/lint`, `script/test`, `script/cibuild` and `script/docker` are the
|
||||||
|
four, and there is no fifth — `script/check` runs the two gate phases and
|
||||||
|
`script/fmt-check`, and builds no image of its own. A bare `docker build .` is
|
||||||
|
not evidence that anything ran: a sub-second build reporting success is a
|
||||||
|
cache hit, not a result. Never invalidate by pruning — `docker builder prune`
|
||||||
|
and friends destroy a build cache shared with every other build on the host.
|
||||||
|
When a check is added or changed, prove it works by planting a defect it must
|
||||||
|
catch and watching the run fail on it, then revert the defect. A green run
|
||||||
|
alone shows neither that the check ran nor that it covers what it should.
|
||||||
|
|
||||||
|
- **The gate phases are separate stages, and the build stage depends on both.**
|
||||||
|
The lint phase is based on the `golangci/golangci-lint` image (pinned by
|
||||||
|
hash), so lint failures surface in seconds rather than after a full compile,
|
||||||
|
and the test phase is based on the Debian Go image. The canonical Go repo
|
||||||
|
`Dockerfile`:
|
||||||
|
|
||||||
```dockerfile
|
```dockerfile
|
||||||
# Lint stage — fast feedback on formatting and lint issues
|
# Lint phase
|
||||||
# golangci/golangci-lint:v2.x.x, YYYY-MM-DD
|
# golangci/golangci-lint:v2.x.x, YYYY-MM-DD
|
||||||
FROM golangci/golangci-lint@sha256:... AS lint
|
FROM golangci/golangci-lint@sha256:... AS lint
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN make fmt-check
|
RUN golangci-lint run --config .golangci.yml ./...
|
||||||
RUN make lint
|
|
||||||
|
|
||||||
# Build stage
|
# Test phase. -race needs cgo and so a C compiler, which the Debian Go
|
||||||
# golang:1.x-alpine, YYYY-MM-DD
|
# image ships and the alpine one does not.
|
||||||
FROM golang@sha256:... AS builder
|
# golang:1.x, YYYY-MM-DD
|
||||||
|
FROM golang@sha256:... AS test
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
|
|
||||||
# Force BuildKit to run the lint stage before proceeding
|
|
||||||
COPY --from=lint /src/go.sum /dev/null
|
|
||||||
|
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN make test
|
RUN go test -timeout 90s -race -cover ./... || \
|
||||||
|
{ echo "--- Rerunning with -v for details ---"; \
|
||||||
|
go test -timeout 90s -race -v ./...; exit 1; }
|
||||||
|
|
||||||
ARG VERSION=dev
|
# Build stage. Nothing is wanted from either phase above; the copies
|
||||||
RUN CGO_ENABLED=0 go build -trimpath \
|
# are what make BuildKit build them first, so this stage cannot run
|
||||||
-ldflags="-s -w -X main.Version=${VERSION}" \
|
# unless lint and test passed.
|
||||||
-o /app ./cmd/app/
|
# golang:1.x-alpine, YYYY-MM-DD
|
||||||
|
FROM golang@sha256:... AS builder
|
||||||
|
COPY --from=lint /src/go.sum /dev/null
|
||||||
|
COPY --from=test /src/go.sum /dev/null
|
||||||
|
RUN apk add --no-cache git
|
||||||
|
# A tar-stream context keeps the sender's file owners, which git refuses.
|
||||||
|
RUN git config --system --add safe.directory /src
|
||||||
|
WORKDIR /src
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
COPY . .
|
||||||
|
|
||||||
# Runtime stage
|
# The VERSION build arg when one is given, otherwise
|
||||||
|
# `git describe --tags --always` on the .git in the build context. With
|
||||||
|
# .git present, a version that is still empty, dev or unknown fails the
|
||||||
|
# build: git is missing or could not read the checkout.
|
||||||
|
ARG VERSION
|
||||||
|
RUN VERSION="${VERSION:-$(git describe --tags --always)}"; \
|
||||||
|
if [ -e .git ]; then \
|
||||||
|
case "$VERSION" in ""|dev|unknown) \
|
||||||
|
echo "version is '$VERSION' although .git is present" >&2; \
|
||||||
|
exit 1 ;; \
|
||||||
|
esac; \
|
||||||
|
fi; \
|
||||||
|
CGO_ENABLED=0 go build -trimpath \
|
||||||
|
-ldflags="-s -w -X main.Version=${VERSION}" \
|
||||||
|
-o /app ./cmd/app/
|
||||||
|
|
||||||
|
# Runtime stage, and the last one
|
||||||
FROM alpine@sha256:...
|
FROM alpine@sha256:...
|
||||||
COPY --from=builder /app /usr/local/bin/app
|
COPY --from=builder /app /usr/local/bin/app
|
||||||
ENTRYPOINT ["app"]
|
ENTRYPOINT ["app"]
|
||||||
```
|
```
|
||||||
|
|
||||||
Key points:
|
Key points:
|
||||||
- The lint stage uses the `golangci/golangci-lint` image directly (it
|
- The lint phase uses the `golangci/golangci-lint` image directly (it has
|
||||||
includes both Go and the linter), so there is no need to install the
|
both Go and the linter), so nothing needs installing.
|
||||||
linter separately.
|
- `COPY --from=<phase> /src/go.sum /dev/null` is a no-op copy whose only
|
||||||
- `COPY --from=lint /src/go.sum /dev/null` is a no-op file copy that creates
|
purpose is the ordering edge. BuildKit runs stages in parallel by default,
|
||||||
a stage dependency. BuildKit runs stages in parallel by default; without
|
and a stage nothing depends on is not built at all, so without these two
|
||||||
this line, the build stage would not wait for lint to finish and a lint
|
lines a red gate would not fail the build.
|
||||||
failure might not fail the overall build.
|
- Keep the runtime stage last, and if you add a stage after it, give it the
|
||||||
|
same two copies. A plain `docker build .` builds the last stage's chain
|
||||||
|
and nothing else.
|
||||||
- If the project uses `//go:embed` directives that reference build artifacts
|
- 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
|
(e.g. a web frontend compiled in a separate stage), the lint phase must
|
||||||
create placeholder files so the embed directives resolve. Example:
|
create placeholder files so the embed directives resolve. Example:
|
||||||
`RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css`.
|
`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
|
- If the project requires CGO or system libraries for linting, install them
|
||||||
fail fast.
|
in the lint phase. The `golangci/golangci-lint` image is Debian-based and
|
||||||
- If the project requires CGO or system libraries for linting (e.g.
|
has no `apk`, so install with `apt-get` under the Debian package name
|
||||||
`vips-dev`), install them in the lint stage with `apk add`.
|
(`libvips-dev`, where alpine says `vips-dev`), and delete the package
|
||||||
- The build stage runs `make test` after compilation setup. Tests run in the
|
lists in the same `RUN`, so the layer does not keep them:
|
||||||
build stage, not the lint stage, because they may require compiled
|
|
||||||
artifacts or heavier dependencies.
|
```dockerfile
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends libvips-dev \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
```
|
||||||
|
|
||||||
|
- `.dockerignore` lets `.git` into the build context. It keeps out every git
|
||||||
|
`config` at any depth (`**/.git/config`, `**/.git/modules/**/config`): the
|
||||||
|
repository's own, each submodule's under `.git/modules/`, and that of a
|
||||||
|
submodule keeping its own `.git` directory. `git describe` does not need
|
||||||
|
them, and each can hold a credential: a password in a remote URL, or the
|
||||||
|
token the CI checkout step stores there. A submodule whose name has a
|
||||||
|
`config` segment (`config`, `deploy/config`, `config/lib`) loses its whole
|
||||||
|
git directory to `**/.git/modules/**/config`, and Go's version stamping
|
||||||
|
then fails the build: give it a name without that segment
|
||||||
|
(`git submodule add --name`). The stage that compiles has `git` (the
|
||||||
|
Debian Go image has it; an alpine one needs `apk add --no-cache git`) and
|
||||||
|
takes the version from the `VERSION` build argument when one is given,
|
||||||
|
otherwise from `git describe --tags --always`. That gives the tag on a
|
||||||
|
tagged commit; on a later commit, the tag, the number of commits since it
|
||||||
|
and the short commit (`v1.2.3-4-gabc1234`); and the short commit when no
|
||||||
|
tag is reachable. The stage that compiles also marks its working directory
|
||||||
|
safe for git (`git config --system --add safe.directory /src`): a context
|
||||||
|
sent as a tar stream keeps the sender's file owners, and git refuses a
|
||||||
|
checkout owned by another user, so the version would come out empty.
|
||||||
|
`ARG VERSION` has no default, and the build fails if the context carries
|
||||||
|
`.git` and the version still comes out empty, `dev` or `unknown`. A plain
|
||||||
|
`docker build .` with no build arguments must succeed; a Dockerfile that
|
||||||
|
refuses an empty build argument drops that refusal and keeps the argument.
|
||||||
|
A checkout whose `.git` is a file (a linked worktree, or a repository
|
||||||
|
checked out as a submodule) is the exception: that file points to a git
|
||||||
|
directory outside the build context, so the build cannot read the version
|
||||||
|
and a plain `docker build .` fails; pass the version with
|
||||||
|
`--build-arg VERSION=...`, as `script/docker` and `script/cibuild` already
|
||||||
|
do.
|
||||||
|
|
||||||
- Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that
|
- Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that
|
||||||
runs `script/cibuild` (which runs `docker build .`) on push. Since the
|
runs `script/cibuild` on push, and checks out the repo as its only other step,
|
||||||
Dockerfile already runs `make check`, a successful build implies all checks
|
with `persist-credentials: false`: `script/cibuild` needs no token, and
|
||||||
pass.
|
without it the checkout leaves the job's token in `.git/config` for every
|
||||||
|
later step. The checkout step also sets `fetch-depth: 0`, which fetches the
|
||||||
|
tags `git describe` needs: by default it clones shallow with no tags, and a
|
||||||
|
tagged repository's CI build would stamp a bare short commit id. The
|
||||||
|
workflow's `concurrency` block groups runs by workflow and branch
|
||||||
|
(`${{ github.workflow }}-${{ github.ref }}`) with `cancel-in-progress: true`,
|
||||||
|
so a new push cancels the older run on the same branch, queued or running, and
|
||||||
|
no other: runs for replaced commits do not hold up the shared runner.
|
||||||
|
`script/cibuild` bootstraps, runs the gate phases, and then builds the image,
|
||||||
|
so a successful run means every check passed; a bare `docker build .` does not
|
||||||
|
carry the same guarantee, because its gate phases may come from the cache. The
|
||||||
|
image build is uncached and so runs the gate phases a second time. That is the
|
||||||
|
price of the rule above, and it is worth paying: the image that ships is built
|
||||||
|
from a run of its own gates rather than from a cache entry. The `check` job
|
||||||
|
sets `timeout-minutes: 20`, so a hung build frees the shared runner after 20
|
||||||
|
minutes. That allows for the three Docker builds described above (the test
|
||||||
|
phase, the lint phase, then the image), each held to the 5-minute Docker build
|
||||||
|
limit below, plus the bootstrap. A separate workflow limited to `main` by a
|
||||||
|
`branches` list under `on: push` cannot be checked by review: to try a change
|
||||||
|
to it, add the feature branch to that list and push, then remove the branch
|
||||||
|
from the list again before merging. Keep any job in it that publishes behind
|
||||||
|
`if: github.ref_name == 'main'`, so the run from the feature branch publishes
|
||||||
|
nothing.
|
||||||
|
|
||||||
- Use platform-standard formatters: `black` for Python, `prettier` for
|
- Use platform-standard formatters: `black` for Python, `prettier` for
|
||||||
JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with
|
JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with
|
||||||
@@ -189,14 +335,21 @@ style conventions are in separate documents:
|
|||||||
module under test to verify it compiles/parses. There is no excuse for
|
module under test to verify it compiles/parses. There is no excuse for
|
||||||
`make test` to be a no-op.
|
`make test` to be a no-op.
|
||||||
|
|
||||||
- `make test` must complete in under 20 seconds. Add a 30-second timeout in the
|
- `make test` must complete in under 60 seconds. That is the hard cap, and a
|
||||||
Makefile.
|
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 (`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
|
- **The test command should use the conditional verbose rerun pattern.** Run
|
||||||
without `-v` (verbose) first. If tests fail, automatically rerun with `-v` to
|
tests without `-v` (verbose) first. If tests fail, automatically rerun with
|
||||||
show full output. This keeps CI logs and `docker build` output clean on
|
`-v` to show full output. This keeps CI logs and `docker build` output clean
|
||||||
success (just package/suite summaries) while providing full diagnostic detail
|
on success (just package/suite summaries) while providing full diagnostic
|
||||||
on failure (every test case, every assertion). The general shell pattern:
|
detail on failure (every test case, every assertion). The command lives in the
|
||||||
|
`test` phase of the `Dockerfile`, since `script/test` builds that phase; the
|
||||||
|
Makefile form below is the same pattern for any repo-local invocation:
|
||||||
|
|
||||||
```makefile
|
```makefile
|
||||||
test:
|
test:
|
||||||
@@ -209,11 +362,26 @@ style conventions are in separate documents:
|
|||||||
|
|
||||||
```makefile
|
```makefile
|
||||||
test:
|
test:
|
||||||
@go test -timeout 30s -race -cover ./... || \
|
@go test -count=1 -timeout 90s -race -cover ./... || \
|
||||||
{ echo "--- Rerunning with -v for details ---"; \
|
{ echo "--- Rerunning with -v for details ---"; \
|
||||||
go test -timeout 30s -race -v ./...; exit 1; }
|
go test -count=1 -timeout 90s -race -v ./...; exit 1; }
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`-count=1` is required on both invocations: it defeats Go's test _result_
|
||||||
|
cache, so neither run can report a stored pass in place of running the
|
||||||
|
tests. It leaves the build cache alone, so it costs the runtime of the suite
|
||||||
|
and no recompilation.
|
||||||
|
|
||||||
|
That cache is Go's own, separate from Docker's layer cache. Go stores a
|
||||||
|
passing result in its cache directory (`GOCACHE`), and when the same tests
|
||||||
|
run again on unchanged code it prints that result, marked `(cached)`,
|
||||||
|
without running them. That matters on a developer's machine, where this
|
||||||
|
target runs and the directory lasts from one run to the next. The `test`
|
||||||
|
phase of the `Dockerfile` needs no `-count=1`: its base image holds no
|
||||||
|
result for this repo's tests and nothing before its `go test` step runs a
|
||||||
|
test, so there is nothing to replay. `--no-cache` (above) is what makes that
|
||||||
|
step run on an unchanged tree.
|
||||||
|
|
||||||
Python example:
|
Python example:
|
||||||
|
|
||||||
```makefile
|
```makefile
|
||||||
@@ -239,10 +407,89 @@ style conventions are in separate documents:
|
|||||||
must be in `.gitignore`. No exceptions.
|
must be in `.gitignore`. No exceptions.
|
||||||
|
|
||||||
- `.gitignore` should be comprehensive from the start: OS files (`.DS_Store`),
|
- `.gitignore` should be comprehensive from the start: OS files (`.DS_Store`),
|
||||||
editor files (`.swp`, `*~`), language build artifacts, and `node_modules/`.
|
editor files (`.swp`, `*~`), in-repo agent scratch directories (`.claude/`),
|
||||||
Fetch the standard `.gitignore` from
|
`node_modules/`, and the repo's own build outputs. Fetch the standard
|
||||||
|
`.gitignore` from
|
||||||
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when setting up
|
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when setting up
|
||||||
a new repo.
|
a new repo. A repo's `.gitignore` is the standard file followed by the repo's
|
||||||
|
own entries, such as its binaries; a re-vendor replaces the standard part and
|
||||||
|
keeps those entries. These patterns are written to `.gitignore`'s own
|
||||||
|
semantics, in which an unanchored pattern already matches at every depth; they
|
||||||
|
are not a `.dockerignore` and must not be transplanted into one unmodified.
|
||||||
|
|
||||||
|
- **`.dockerignore` does not use `.gitignore` semantics, and copying patterns
|
||||||
|
across unmodified leaves secrets in the build context.** Docker matches with
|
||||||
|
`moby/patternmatcher`: `filepath.Match` semantics plus a `**` extension, so
|
||||||
|
`*` does not cross `/` and a pattern without a leading `**/` is anchored at
|
||||||
|
the build-context root. A `.dockerignore` listing `.env`, `*.pem` and `*.key`
|
||||||
|
therefore excludes only the copies at the repository root, while `config/.env`
|
||||||
|
and `certs/server.key` still reach the context and can land in an image layer
|
||||||
|
— which is more dangerous than a short file with no secret patterns at all,
|
||||||
|
because it reads as solved and stops anyone looking. Give every
|
||||||
|
depth-independent pattern the `**/` prefix and leave only genuinely
|
||||||
|
root-anchored entries unprefixed: `.claude`, and the repo's own host-built
|
||||||
|
binary, written `/myapp` and never `**/myapp`, which would also match
|
||||||
|
`cmd/myapp/` and delete the package directory from the context. Matching is
|
||||||
|
case-sensitive, and an ALL-CAPS twin per pattern still misses `Server.Key`, so
|
||||||
|
secret names use character ranges — `**/*.[kK][eE][yY]`, `**/*.[pP][eE][mM]`,
|
||||||
|
and likewise for `.envrc` and the extensionless SSH keys. Where such a pattern
|
||||||
|
also catches something the build needs, re-include it with a negation
|
||||||
|
(`!docs/example.env`); deleting the pattern reopens the exposure for every
|
||||||
|
other file it covers. Fetch the standard `.dockerignore` from
|
||||||
|
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.dockerignore` and extend
|
||||||
|
it with the repo's own artifacts.
|
||||||
|
|
||||||
|
- **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 under `COPY . .` the build context
|
||||||
|
inflates by a multiple of the repo and another session's unreviewed work can
|
||||||
|
be copied into an image layer. In `.gitignore` the entry is `.claude/`,
|
||||||
|
unanchored. In `.dockerignore` it is `.claude`, anchored and with **no** `**/`
|
||||||
|
prefix, because the prefixed form would also delete any nested directory of
|
||||||
|
that name from the build. Anchoring carries a known gap that the canonical
|
||||||
|
`.dockerignore` states in its own comment, since consuming repos receive the
|
||||||
|
file and not the tracker: the directory is created in the agent's working
|
||||||
|
directory, so a repo running agents in subdirectories still ships
|
||||||
|
`services/api/.claude/` and must add its own anchored entry there.
|
||||||
|
|
||||||
|
- **A plain `docker build .` of a clone stamps the version that
|
||||||
|
`git describe --tags --always` gives**, derived from the `.git` in the build
|
||||||
|
context as the canonical `Dockerfile` above shows. Without its failure check,
|
||||||
|
a missing `git` or an unreadable checkout would leave `-X main.Version=` empty
|
||||||
|
and the build would still exit 0. `script/docker` and `script/cibuild` pass
|
||||||
|
the version they compute on the host; it takes precedence. They do this
|
||||||
|
byte-identically across repos:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# The version and the tag each get their own line: a failing command
|
||||||
|
# substitution inside an argument does not trip `set -e`, so the inline
|
||||||
|
# form degrades to an empty constant.
|
||||||
|
version="$(git describe --tags --always --dirty 2>/dev/null || true)"
|
||||||
|
[ -n "$version" ] || version="unknown"
|
||||||
|
tag="$(script/projectname)"
|
||||||
|
docker build --no-cache \
|
||||||
|
--build-arg VERSION="$version" \
|
||||||
|
-t "$tag" .
|
||||||
|
```
|
||||||
|
|
||||||
|
`--always` makes an untagged repo yield an abbreviated commit hash rather
|
||||||
|
than failing, and the `[ -n "$version" ]` line is the single place the
|
||||||
|
fallback is applied — a live check that fires on a build from an export with
|
||||||
|
no `.git` and on a repository with no commits yet. Do not fold it into the
|
||||||
|
substitution as `|| echo unknown`, which makes the guard unreachable. The
|
||||||
|
Dockerfile's side is `ARG VERSION` in the stage that compiles, declared
|
||||||
|
there because `ARG` is stage-scoped; passing `VERSION` to a repo whose
|
||||||
|
Dockerfile declares no such `ARG` is ignored and costs nothing, which is why
|
||||||
|
the scripts stay byte-identical. One consequence for CI: the standard
|
||||||
|
checkout action clones shallow and fetches no tags, so the canonical
|
||||||
|
`.gitea/workflows/check.yml` sets `fetch-depth: 0` on its checkout step.
|
||||||
|
|
||||||
|
- **Verify `.dockerignore` by enumerating the image, not by reading the
|
||||||
|
patterns.** Plant files at the root _and_ at least two directories deep, build
|
||||||
|
a probe image that does `COPY . .`, and list what actually landed
|
||||||
|
(`docker run --rm --entrypoint find IMAGE /app`). The `transferring context`
|
||||||
|
size is not a substitute: a nested secret is a few bytes, and BuildKit
|
||||||
|
transfers only the delta from the previous build.
|
||||||
|
|
||||||
- **No build artifacts in version control.** Code-derived data (compiled
|
- **No build artifacts in version control.** Code-derived data (compiled
|
||||||
bundles, minified output, generated assets) must never be committed to the
|
bundles, minified output, generated assets) must never be committed to the
|
||||||
@@ -258,9 +505,56 @@ style conventions are in separate documents:
|
|||||||
- Make all changes on a feature branch. You can do whatever you want on a
|
- Make all changes on a feature branch. You can do whatever you want on a
|
||||||
feature branch.
|
feature branch.
|
||||||
|
|
||||||
- `.golangci.yml` is standardized and must _NEVER_ be modified by an agent, only
|
- `.golangci.yml` is standardized. The vendored copy in a consuming repo must
|
||||||
manually by the user. Fetch from
|
_NEVER_ be modified by an agent: fetch it from
|
||||||
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml`.
|
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml` and keep it
|
||||||
|
byte-identical, so that no repo can quietly loosen its own linting. Linter
|
||||||
|
configuration changes are made to the canonical copy in the `prompts` repo and
|
||||||
|
reach consuming repos by re-vendoring; an agent may open a PR against
|
||||||
|
canonical, which only the user merges. One list is exempt from byte-identity,
|
||||||
|
because it cannot be written once for every repo: the `deny` list of the
|
||||||
|
`test-support` depguard rule, where a repo names its own test-support packages
|
||||||
|
by full import path. A repo adds entries there and changes nothing else, and a
|
||||||
|
re-vendor carries its entries forward. The canonical golangci-lint version is
|
||||||
|
v2.14.0 (released 2026-09-24), pinned as the digest of the lint phase's base
|
||||||
|
image
|
||||||
|
(`golangci/golangci-lint@sha256:ad862ba6b3798cbe0fd9fd7408d498fd74fbd2623a92406b2fd3898faf0bf98f`,
|
||||||
|
which reports `2.14.0 built with go1.27.0 from 114493f9`). A module's `go`
|
||||||
|
directive must not name a newer Go minor version than the one golangci-lint
|
||||||
|
was built with, or golangci-lint refuses to lint it: this release lints
|
||||||
|
`go 1.27.1` but not `go 1.28`. That digest is the only pin, since no repo
|
||||||
|
installs golangci-lint on the host. A repo sets the lint phase digest to the
|
||||||
|
one named here and re-vendors `.golangci.yml` in the same commit, whichever of
|
||||||
|
the two prompted the change: the canonical copy can name linters that an older
|
||||||
|
golangci-lint rejects, and a newer golangci-lint can add linters that
|
||||||
|
`default: all` switches on until the canonical copy disables them.
|
||||||
|
|
||||||
|
- **`script/bootstrap` installs a pinned tool by comparing versions, never by
|
||||||
|
testing presence.** An `if ! command -v <tool>; then install; fi` guard tests
|
||||||
|
`PATH` only, so on an already-provisioned machine the pin is inert and a
|
||||||
|
version bump is a silent no-op — while the Dockerfile, installing into a clean
|
||||||
|
image, gets the pinned version, so a local `make check` and `make docker` can
|
||||||
|
disagree about what the tool even is. The canonical form:
|
||||||
|
- compares the installed version against the pin over the **whole** version
|
||||||
|
token; a parser that stops at the first `-` reports `2.12.2` for a host
|
||||||
|
running `2.12.2-rc1` and skips the install;
|
||||||
|
- treats absent, non-zero, empty or unrecognised `--version` output as a
|
||||||
|
mismatch, so the failure direction is a redundant install and never a
|
||||||
|
skipped one;
|
||||||
|
- after installing, re-resolves the binary the way callers do — `hash -r`,
|
||||||
|
then through `PATH`, not through the directory the installer wrote to —
|
||||||
|
and fails naming the resolved path, since an install that a shadowing
|
||||||
|
binary hides succeeds while changing nothing any caller sees;
|
||||||
|
- is actually called, and prints the version on both success paths: a
|
||||||
|
function defined and never invoked has the same exit status and the same
|
||||||
|
empty output as one that worked.
|
||||||
|
|
||||||
|
Keep it POSIX sh: no arrays, no `[[`, no `grep -P`.
|
||||||
|
|
||||||
|
A Go tool a repo needs on the host is installed with `go install` pinned to
|
||||||
|
a commit hash (`go install <package>@<commit hash>`). It is never tracked as
|
||||||
|
a `go.mod` tool dependency or through a `tools.go` file, either of which
|
||||||
|
pulls the tool's own dependencies into the repo's `go.mod` and `go.sum`.
|
||||||
|
|
||||||
- When pinning images or packages by hash, add a comment above the reference
|
- When pinning images or packages by hash, add a comment above the reference
|
||||||
with the version and date (YYYY-MM-DD).
|
with the version and date (YYYY-MM-DD).
|
||||||
@@ -371,15 +665,21 @@ style conventions are in separate documents:
|
|||||||
Never edit existing migrations after release.
|
Never edit existing migrations after release.
|
||||||
|
|
||||||
- All repos should have an `.editorconfig` enforcing the project's indentation
|
- All repos should have an `.editorconfig` enforcing the project's indentation
|
||||||
settings.
|
settings: the standard file from
|
||||||
|
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.editorconfig`, which sets
|
||||||
|
tabs for `Makefile` and Go files, followed by the repo's own sections, such as
|
||||||
|
one for another language it uses. A re-vendor replaces the standard part and
|
||||||
|
keeps those sections.
|
||||||
|
|
||||||
- Avoid putting files in the repo root unless necessary. Root should contain
|
- Avoid putting files in the repo root unless necessary. Root should contain
|
||||||
only project-level config files (`README.md`, `Makefile`, `Dockerfile`,
|
only project-level config files (`README.md`, `AGENTS.md`, `Makefile`,
|
||||||
`LICENSE`, `.gitignore`, `.editorconfig`, `REPO_POLICIES.md`, and
|
`Dockerfile`, `LICENSE`, `.gitignore`, `.editorconfig`, `REPO_POLICIES.md`,
|
||||||
language-specific config). Everything else goes in a subdirectory. Canonical
|
and language-specific config). Everything else goes in a subdirectory.
|
||||||
subdirectory names:
|
Canonical subdirectory names:
|
||||||
- `bin/` — executable scripts and tools
|
- `bin/` — executable scripts and tools
|
||||||
- `cmd/` — Go command entrypoints
|
- `cmd/` — Go command entrypoints; thin only: one `main.go` per binary whose
|
||||||
|
body is a single call into `internal/` or `pkg/`, no project logic in
|
||||||
|
`cmd/`
|
||||||
- `configs/` — configuration templates and examples
|
- `configs/` — configuration templates and examples
|
||||||
- `deploy/` — deployment manifests (k8s, compose, terraform)
|
- `deploy/` — deployment manifests (k8s, compose, terraform)
|
||||||
- `docs/` — documentation and markdown (README.md stays in root)
|
- `docs/` — documentation and markdown (README.md stays in root)
|
||||||
@@ -406,3 +706,7 @@ style conventions are in separate documents:
|
|||||||
- Go: `go.mod`, `go.sum`, `.golangci.yml`
|
- Go: `go.mod`, `go.sum`, `.golangci.yml`
|
||||||
- JS: `package.json`, `yarn.lock`, `.prettierrc`, `.prettierignore`
|
- JS: `package.json`, `yarn.lock`, `.prettierrc`, `.prettierignore`
|
||||||
- Python: `pyproject.toml`
|
- Python: `pyproject.toml`
|
||||||
|
|
||||||
|
- Guidance for coding agents lives in one `AGENTS.md` at the repository root. It
|
||||||
|
is never committed under a file or directory named after one agent tool, such
|
||||||
|
as `CLAUDE.md` or `.claude/`, and never split into separate memory files.
|
||||||
|
|||||||
@@ -29,6 +29,17 @@
|
|||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
|
- re-vendor the canonical files and model scripts from `sneak/prompts` `next` at
|
||||||
|
`c55a0cb`: golangci-lint v2.14.0; lint and test are phases of the `Dockerfile`
|
||||||
|
that write no image, and `make test` runs the suite under the race detector,
|
||||||
|
so `Dockerfile.lint`, `script/verify-lint-image-pin` and `make test-race` are
|
||||||
|
gone; every `docker build` in `script/` passes `--no-cache`; prettier runs on
|
||||||
|
the host, from the node and yarn `script/bootstrap` installs, so the
|
||||||
|
`prettier` and `markdown` stages are gone and the build stage installs `git`
|
||||||
|
and `make` itself; a new push cancels the workflow's older run on the same
|
||||||
|
branch, and a run stops after 20 minutes; `.claude/settings.json` is deleted
|
||||||
|
(2026-10-08, https://git.eeqj.de/sneak/sfdupes/issues/95)
|
||||||
|
|
||||||
- `scan` records mtime to the nanosecond, as whole seconds in `mtime` plus
|
- `scan` records mtime to the nanosecond, as whole seconds in `mtime` plus
|
||||||
`mtime_nsec`, and compares it at that resolution, so a same-size rewrite
|
`mtime_nsec`, and compares it at that resolution, so a same-size rewrite
|
||||||
within the same second is re-hashed (2026-10-07,
|
within the same second is re-hashed (2026-10-07,
|
||||||
@@ -38,16 +49,22 @@
|
|||||||
`script/` and both Dockerfiles; §Workflow now branches from and merges to
|
`script/` and both Dockerfiles; §Workflow now branches from and merges to
|
||||||
`next` (2026-10-04, https://git.eeqj.de/sneak/sfdupes/issues/49)
|
`next` (2026-10-04, https://git.eeqj.de/sneak/sfdupes/issues/49)
|
||||||
|
|
||||||
- `make test-race` runs the test suite under the race detector in a cgo-enabled
|
- `make test-race` ran the test suite under the race detector in a cgo-enabled
|
||||||
container, outside `make check` (2026-10-04,
|
container, outside `make check` (2026-10-04,
|
||||||
https://git.eeqj.de/sneak/sfdupes/issues/18)
|
https://git.eeqj.de/sneak/sfdupes/issues/18). Since
|
||||||
|
https://git.eeqj.de/sneak/sfdupes/issues/95 `make test` itself runs the suite
|
||||||
|
under the race detector, in the `Dockerfile`'s Debian-based `test` phase, and
|
||||||
|
`make test-race` is gone
|
||||||
|
|
||||||
- a bare `docker build .` fails with a message naming `script/cibuild` and
|
- a bare `docker build .` failed, naming `script/cibuild` and `script/docker`,
|
||||||
`script/docker` instead of serving the gates from cache (2026-10-04,
|
rather than serve the gates from cache (2026-10-04,
|
||||||
https://git.eeqj.de/sneak/sfdupes/issues/39)
|
https://git.eeqj.de/sneak/sfdupes/issues/39). Since
|
||||||
|
https://git.eeqj.de/sneak/sfdupes/issues/95 a bare build succeeds, as
|
||||||
|
`REPO_POLICIES.md` requires, and may serve the gates from cache; the builds in
|
||||||
|
`script/` pass `--no-cache`, so theirs always run
|
||||||
|
|
||||||
- `make fmt` and `make fmt-check` run prettier over all Markdown, in Docker, and
|
- `make fmt` and `make fmt-check` run prettier over all Markdown, and CI checks
|
||||||
CI checks it; all Markdown reformatted (2026-10-04,
|
it; all Markdown reformatted (2026-10-04,
|
||||||
https://git.eeqj.de/sneak/sfdupes/issues/19)
|
https://git.eeqj.de/sneak/sfdupes/issues/19)
|
||||||
|
|
||||||
- `script/lint` writes no image, so a run no longer leaves an untagged one
|
- `script/lint` writes no image, so a run no longer leaves an untagged one
|
||||||
@@ -90,9 +107,12 @@
|
|||||||
- README documents install, Docker, a daily cron scan and how to read and check
|
- README documents install, Docker, a daily cron scan and how to read and check
|
||||||
the reports (2026-10-04, https://git.eeqj.de/sneak/sfdupes/issues/54)
|
the reports (2026-10-04, https://git.eeqj.de/sneak/sfdupes/issues/54)
|
||||||
|
|
||||||
- the `Dockerfile` build stage keeps the Go module cache out of `builder`'s home
|
- the `Dockerfile` build stage kept the Go module cache out of `builder`'s home
|
||||||
and copies the sources with `--chown`, so no `chown -R` walks them
|
and copied the sources with `--chown`, so no `chown -R` walked them
|
||||||
(2026-10-04, https://git.eeqj.de/sneak/sfdupes/issues/43)
|
(2026-10-04, https://git.eeqj.de/sneak/sfdupes/issues/43). Since
|
||||||
|
https://git.eeqj.de/sneak/sfdupes/issues/95 there is no `builder` user and
|
||||||
|
nothing changes owner: the build stage only compiles, as root, and the tests
|
||||||
|
run as `nobody` in the `test` phase
|
||||||
|
|
||||||
- `--version` prints `sfdupes VERSION` to stdout; README documents it and
|
- `--version` prints `sfdupes VERSION` to stdout; README documents it and
|
||||||
`--help` (2026-10-04, https://git.eeqj.de/sneak/sfdupes/issues/15)
|
`--help` (2026-10-04, https://git.eeqj.de/sneak/sfdupes/issues/15)
|
||||||
@@ -172,32 +192,28 @@
|
|||||||
`ENV PATH=/home/builder/go/bin:$PATH` went with its `go install`.
|
`ENV PATH=/home/builder/go/bin:$PATH` went with its `go install`.
|
||||||
`script/verify-linter-pin` is retired; `script/verify-lint-image-pin`, a gate
|
`script/verify-linter-pin` is retired; `script/verify-lint-image-pin`, a gate
|
||||||
in both files, compares their two `FROM` lines and restates neither pin.
|
in both files, compares their two `FROM` lines and restates neither pin.
|
||||||
Traps: an unchanged tree lets a lint build pass in under a second having run
|
Traps: nothing inside an image build may shell out to docker, so the
|
||||||
no linter, so every gate `RUN` references `ARG CHECK_EPOCH` (BuildKit hashes
|
`Dockerfile` lint stage calls `golangci-lint` directly and the build stage
|
||||||
the expanded command) and `script/lint` passes `"$(date +%s)-$$"`, the PID
|
runs `make test` and `make fmt-check` instead of `make check`, through `make`
|
||||||
because two runs land in the same second easily. Nothing inside an image build
|
because the Makefile's `export CGO_ENABLED = 0` only reaches what it invokes.
|
||||||
may shell out to docker, so the `Dockerfile` lint stage calls `golangci-lint`
|
`COPY --from=lint /src/go.sum /dev/null` replaces the copied linter binary as
|
||||||
directly and the build stage runs `make test` and `make fmt-check` instead of
|
the only edge making the build stage wait for lint; dropping it would end
|
||||||
`make check`, through `make` because the Makefile's `export CGO_ENABLED = 0`
|
fail-fast linting under a still-green build. `golangci-lint config verify`,
|
||||||
only reaches what it invokes. `COPY --from=lint /src/go.sum /dev/null`
|
included per the ruling, validates from an embedded schema with no network
|
||||||
replaces the copied linter binary as the only edge making the build stage wait
|
call, but `go mod download` above the gates still needs the network on a cold
|
||||||
for lint; dropping it would end fail-fast linting under a still-green build.
|
cache. Verified: `make lint` green with no `golangci-lint` on `PATH`; two
|
||||||
`golangci-lint config verify`, included per the ruling, validates from an
|
back-to-back `script/lint` runs on an untouched tree both ran the linter
|
||||||
embedded schema with no network call, but `go mod download` above the gates
|
(27.7s and 28.7s in the lint step, `COPY . .` `CACHED` above); a planted
|
||||||
still needs the network on a cold cache. Verified: `make lint` green with no
|
unused variable failed `script/lint`, and failed `make docker` at `[lint 9/9]`
|
||||||
`golangci-lint` on `PATH`; two back-to-back `script/lint` runs on an untouched
|
with the build stage stopped at `[builder 3/12]`; the drift guard fails on a
|
||||||
tree both ran the linter (27.7s and 28.7s in the lint step, `COPY . .`
|
tag-only, a digest-only and an unreadable reference, naming both sides; under
|
||||||
`CACHED` above); a planted unused variable failed `script/lint`, and failed
|
`--network none` config verify passes a valid config and rejects an invalid
|
||||||
`make docker` at `[lint 9/9]` with the build stage stopped at
|
one; `make docker` green in 5m35s with all six gates run (lint 37.6s, test
|
||||||
`[builder 3/12]`; the drift guard fails on a tag-only, a digest-only and an
|
25.2s reporting `ok sneak.berlin/go/sfdupes 1.938s coverage: 88.5%`, not
|
||||||
unreadable reference, naming both sides; under `--network none` config verify
|
`(cached)`); in the builder image with the Go test cache off, `--user 0:0`
|
||||||
passes a valid config and rejects an invalid one; `make docker` green in 5m35s
|
still fails `TestScanHardlinkRunFailsTogether` where the unprivileged user
|
||||||
with all six gates run under one epoch (lint 37.6s, test 25.2s reporting
|
passes. Noted for follow-up, not fixed here: `golangci-lint` warns that
|
||||||
`ok sneak.berlin/go/sfdupes 1.938s coverage: 88.5%`, not `(cached)`); in the
|
`gomodguard` is deprecated since v2.12.0 in favour of `gomodguard_v2`.
|
||||||
builder image with the Go test cache off, `--user 0:0` still fails
|
|
||||||
`TestScanHardlinkRunFailsTogether` where the unprivileged user passes. Noted
|
|
||||||
for follow-up, not fixed here: `golangci-lint` warns that `gomodguard` is
|
|
||||||
deprecated since v2.12.0 in favour of `gomodguard_v2`.
|
|
||||||
|
|
||||||
- install the Docker build stage's prerequisites by running `script/bootstrap`
|
- install the Docker build stage's prerequisites by running `script/bootstrap`
|
||||||
instead of `apk add --no-cache make` inline (2026-08-09, branch
|
instead of `apk add --no-cache make` inline (2026-08-09, branch
|
||||||
@@ -210,43 +226,32 @@
|
|||||||
`script/verify-linter-pin`, run in the build stage before bootstrap, fails the
|
`script/verify-linter-pin`, run in the build stage before bootstrap, fails the
|
||||||
build naming both versions unless that copied binary is the version
|
build naming both versions unless that copied binary is the version
|
||||||
`script/bootstrap` pins; a pin it cannot read is a hard failure, not a skip.
|
`script/bootstrap` pins; a pin it cannot read is a hard failure, not a skip.
|
||||||
`$GOPATH/bin` joins `PATH`, where bootstrap's `go install` lands. Everything
|
`$GOPATH/bin` joins `PATH`, where bootstrap's `go install` lands. The `chown`
|
||||||
added sits above `ARG CHECK_EPOCH`, and the `chown` and `USER builder` still
|
and `USER builder` still precede `make check`. Verified: the guard fails the
|
||||||
precede `make check`. Verified: the guard fails the build with both versions
|
build with both versions named when the lint stage's linter is faked to
|
||||||
named when the lint stage's linter is faked to another version, and passes an
|
another version, and passes an unmodified build; bootstrap runs clean under
|
||||||
unmodified build; bootstrap runs clean under Alpine's `sh` and `apk`, finding
|
Alpine's `sh` and `apk`, finding the copied linter already at the pin; a
|
||||||
the copied linter already at the pin; a second build served the bootstrap and
|
second build served the bootstrap and dependency layers `CACHED` while both
|
||||||
dependency layers `CACHED` while both gates ran with a fresh epoch; a planted
|
gates ran; a planted `unused` finding failed the build at the lint gate in
|
||||||
`unused` finding failed the build at the lint gate in 48.9s with the build
|
48.9s with the build stage's `make check` never starting; and the suite run in
|
||||||
stage's `make check` never starting; and the suite run in the image as
|
the image as `--user 0:0` fails `TestScanHardlinkRunFailsTogether`, so the
|
||||||
`--user 0:0` fails `TestScanHardlinkRunFailsTogether`, so the drop to the
|
drop to the unprivileged user is still needed. That last check needs the Go
|
||||||
unprivileged user is still needed. That last check needs the Go test cache
|
test cache off: as root it first reported `ok ... (cached)`, reusing the
|
||||||
off: as root it first reported `ok ... (cached)`, reusing the build-time
|
build-time result. Build times on a noisy shared host: 2m13s on an unchanged
|
||||||
result. Build times on a noisy shared host: 2m13s on an unchanged tree, 2m17s
|
tree, 2m17s and 4m29s after a source change, 5m14s cold, which breaches the
|
||||||
and 4m29s after a source change, 5m14s cold, which breaches the policy
|
policy ceiling; `chown -R builder:builder /src /home/builder` walks the module
|
||||||
ceiling; `chown -R builder:builder /src /home/builder` walks the module cache
|
cache and alone varied from 77s to 210s across those builds, and `main`
|
||||||
and alone varied from 77s to 210s across those builds, and `main` measured
|
measured 5m03s cold with a 209s `chown`. Filed as
|
||||||
5m03s cold with a 209s `chown`. Filed as
|
|
||||||
https://git.eeqj.de/sneak/sfdupes/issues/43
|
https://git.eeqj.de/sneak/sfdupes/issues/43
|
||||||
- bust the Docker layer cache for the gate steps, so `script/cibuild` and
|
- bust the Docker layer cache for the gate steps, so `script/cibuild` and
|
||||||
`script/docker` cannot report a green they did not earn (2026-08-09, branch
|
`script/docker` cannot report a green they did not earn (2026-08-09, branch
|
||||||
`cibuild-cache-bust`, closes https://git.eeqj.de/sneak/sfdupes/issues/32): the
|
`cibuild-cache-bust`, closes https://git.eeqj.de/sneak/sfdupes/issues/32): the
|
||||||
`Dockerfile` copies the tree before its gates, so on an unchanged tree Docker
|
`Dockerfile` copies the tree before its gates, so on an unchanged tree Docker
|
||||||
served them from cache and the build exited 0 having run nothing. Both scripts
|
served them from cache and the build exited 0 having run nothing. Every
|
||||||
now pass `--build-arg CHECK_EPOCH="$(date +%s)"`. `ARG` is per stage and the
|
`docker build` in `script/` now passes `--no-cache` instead
|
||||||
gates span two stages, so it is declared in both; BuildKit hashes the expanded
|
(https://git.eeqj.de/sneak/sfdupes/issues/95). Run as root, the tests fail
|
||||||
command, so each gate `RUN` echoes the epoch, which also logs it as evidence
|
`TestScanHardlinkRunFailsTogether`, because root reads through the `chmod(0)`
|
||||||
the layer ran. It sits below the dependency layers so they stay cached.
|
the test relies on, so they run as an unprivileged user
|
||||||
Verified under `BUILDKIT_PROGRESS=plain`, each script run twice back to back
|
|
||||||
on an unchanged tree: all three gates ran on all four runs with a fresh epoch
|
|
||||||
(`script/cibuild` 78.8s then 61.1s; `script/docker` 61.1s then 53.4s), and
|
|
||||||
thirteen steps were still served `CACHED`. With a planted `unused` finding the
|
|
||||||
build failed at `make lint` in 36.1s and the build-stage `make check` never
|
|
||||||
started. Run as root, the same image fails `TestScanHardlinkRunFailsTogether`,
|
|
||||||
because root reads through the `chmod(0)` the test relies on, so the build
|
|
||||||
stage must drop to the unprivileged `builder` user. Local fix only;
|
|
||||||
propagating it to the canonical templates is
|
|
||||||
https://git.eeqj.de/sneak/prompts/issues/26
|
|
||||||
- check the installed golangci-lint version in `script/bootstrap` instead of
|
- check the installed golangci-lint version in `script/bootstrap` instead of
|
||||||
only its presence (2026-08-09, branch `bootstrap-version-check`, closes
|
only its presence (2026-08-09, branch `bootstrap-version-check`, closes
|
||||||
https://git.eeqj.de/sneak/sfdupes/issues/24): the version lives only in
|
https://git.eeqj.de/sneak/sfdupes/issues/24): the version lives only in
|
||||||
@@ -402,6 +407,3 @@ Accepted divergences (no action):
|
|||||||
|
|
||||||
- flat single-package layout with `.go` files in the repo root — fine for a
|
- flat single-package layout with `.go` files in the repo root — fine for a
|
||||||
small single-binary tool per the Go styleguide; the tracker audit agrees
|
small single-binary tool per the Go styleguide; the tracker audit agrees
|
||||||
- `make test` runs without `-race` — the repo mandates `CGO_ENABLED=0` (pure-Go
|
|
||||||
builds) and the race detector requires cgo, so the detector runs in a separate
|
|
||||||
cgo-enabled container, `make test-race`, which is not part of `make check`
|
|
||||||
|
|||||||
+84
-16
@@ -1,13 +1,22 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# script/bootstrap: install all dependencies needed to build and develop
|
# script/bootstrap: install all dependencies needed to build and develop
|
||||||
# this repo. Idempotent; assumes nothing is present (not git, make, or
|
# this repo. Idempotent: every install is guarded by a check so already
|
||||||
# go). Base tooling comes from nix, apt, brew, or apk (detected in that
|
# installed tools are skipped. Base tooling comes from nix, apt, brew,
|
||||||
# order). golangci-lint and prettier are never installed: they run via
|
# or apk (detected in that order); assumes nothing is present. Node is
|
||||||
# docker only (script/lint, script/fmt, script/fmt-check).
|
# used directly if installed; otherwise it is installed at a pinned
|
||||||
|
# version via nvm (installing nvm itself first, from a hash-verified
|
||||||
|
# release archive, never curl | sh).
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||||
|
|
||||||
|
# Pinned versions, 2026-07-06
|
||||||
|
NODE_VERSION="22.17.0"
|
||||||
|
NVM_VERSION="0.40.3"
|
||||||
|
# sha256 of https://github.com/nvm-sh/nvm/archive/refs/tags/v0.40.3.tar.gz
|
||||||
|
NVM_SHA256="5f4d6aaa04a177dc93c985e31dbc411ab6b8c6e1e21d8015dbc1372625fcd1d0"
|
||||||
|
YARN_VERSION="1.22.22"
|
||||||
|
|
||||||
PKGMGR=""
|
PKGMGR=""
|
||||||
SUDO=""
|
SUDO=""
|
||||||
APT_UPDATED=""
|
APT_UPDATED=""
|
||||||
@@ -40,6 +49,7 @@ pkg_install() {
|
|||||||
case "$PKGMGR" in
|
case "$PKGMGR" in
|
||||||
nix) nix-env -iA "nixpkgs.$1" ;;
|
nix) nix-env -iA "nixpkgs.$1" ;;
|
||||||
apt)
|
apt)
|
||||||
|
# Package lists may be empty (fresh images); refresh once per run.
|
||||||
if [ -z "$APT_UPDATED" ]; then
|
if [ -z "$APT_UPDATED" ]; then
|
||||||
$SUDO env DEBIAN_FRONTEND=noninteractive apt-get update
|
$SUDO env DEBIAN_FRONTEND=noninteractive apt-get update
|
||||||
APT_UPDATED=1
|
APT_UPDATED=1
|
||||||
@@ -55,24 +65,82 @@ missing() {
|
|||||||
! command -v "$1" >/dev/null 2>&1
|
! command -v "$1" >/dev/null 2>&1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# verify_sha256 <file> <expected-hash>
|
||||||
|
verify_sha256() {
|
||||||
|
if command -v sha256sum >/dev/null 2>&1; then
|
||||||
|
actual="$(sha256sum "$1" | cut -d' ' -f1)"
|
||||||
|
else
|
||||||
|
actual="$(shasum -a 256 "$1" | cut -d' ' -f1)"
|
||||||
|
fi
|
||||||
|
if [ "$actual" != "$2" ]; then
|
||||||
|
echo "bootstrap: sha256 mismatch for $1" >&2
|
||||||
|
echo " expected: $2" >&2
|
||||||
|
echo " actual: $actual" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# nvm is a bash script; run a command in a bash with nvm loaded
|
||||||
|
nvm_sh() {
|
||||||
|
bash -c ". \"\$HOME/.nvm/nvm.sh\" && $*"
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_nvm() {
|
||||||
|
[ -s "$HOME/.nvm/nvm.sh" ] && return 0
|
||||||
|
# nvm prerequisites; nvm itself requires bash
|
||||||
|
if missing bash; then pkg_install bash bash bash bash; fi
|
||||||
|
if missing curl; then pkg_install curl curl curl curl; fi
|
||||||
|
if missing git; then pkg_install git git git git; fi
|
||||||
|
tmp="$(mktemp -d)"
|
||||||
|
curl -fsSL -o "$tmp/nvm.tar.gz" \
|
||||||
|
"https://github.com/nvm-sh/nvm/archive/refs/tags/v${NVM_VERSION}.tar.gz"
|
||||||
|
verify_sha256 "$tmp/nvm.tar.gz" "$NVM_SHA256"
|
||||||
|
mkdir -p "$HOME/.nvm"
|
||||||
|
tar -xzf "$tmp/nvm.tar.gz" -C "$HOME/.nvm" --strip-components=1
|
||||||
|
rm -rf "$tmp"
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_node() {
|
||||||
|
if ! missing node; then return 0; fi
|
||||||
|
ensure_nvm
|
||||||
|
nvm_sh "nvm install $NODE_VERSION"
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_yarn() {
|
||||||
|
if ! missing yarn; then return 0; fi
|
||||||
|
if ! missing corepack; then
|
||||||
|
corepack enable
|
||||||
|
corepack prepare "yarn@$YARN_VERSION" --activate
|
||||||
|
elif [ -s "$HOME/.nvm/nvm.sh" ]; then
|
||||||
|
nvm_sh "nvm use $NODE_VERSION >/dev/null && corepack enable && \
|
||||||
|
corepack prepare yarn@$YARN_VERSION --activate"
|
||||||
|
else
|
||||||
|
npm install -g "yarn@$YARN_VERSION"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
install_js_deps() {
|
||||||
|
if missing yarn && [ -s "$HOME/.nvm/nvm.sh" ]; then
|
||||||
|
nvm_sh "nvm use $NODE_VERSION >/dev/null && cd \"$ROOT\" && \
|
||||||
|
yarn install --frozen-lockfile"
|
||||||
|
else
|
||||||
|
yarn install --frozen-lockfile
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
main() {
|
main() {
|
||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
|
|
||||||
# Deliberately unpinned, so presence is the whole check: go.mod
|
|
||||||
# governs the Go version, and reproducible builds run in the
|
|
||||||
# digest-pinned Docker images.
|
|
||||||
if missing git; then pkg_install git git git git; fi
|
|
||||||
if missing make; then pkg_install gnumake make make make; fi
|
if missing make; then pkg_install gnumake make make make; fi
|
||||||
|
if missing git; then pkg_install git git git git; fi
|
||||||
|
# Go builds the binary and runs gofmt. Presence is the whole check:
|
||||||
|
# go.mod names the Go version, and the tests and the linter run in
|
||||||
|
# digest-pinned images.
|
||||||
if missing go; then pkg_install go golang go go; fi
|
if missing go; then pkg_install go golang go go; fi
|
||||||
|
|
||||||
# Warn, do not fail: only the targets named below, and the
|
ensure_node
|
||||||
# pre-commit hook, need docker.
|
ensure_yarn
|
||||||
if missing docker; then
|
install_js_deps
|
||||||
echo "bootstrap: WARNING: docker not found; make lint, make fmt," >&2
|
|
||||||
echo "bootstrap: make fmt-check, make check, make docker and" >&2
|
|
||||||
echo "bootstrap: make test-race require it." >&2
|
|
||||||
fi
|
|
||||||
|
|
||||||
go mod download
|
go mod download
|
||||||
|
|
||||||
echo "bootstrap complete"
|
echo "bootstrap complete"
|
||||||
|
|||||||
+3
-1
@@ -1,6 +1,8 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# script/check: run all checks (test, lint, fmt-check). Our own
|
# 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
|
set -eu
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
||||||
|
|||||||
+21
-10
@@ -1,19 +1,30 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# script/cibuild: run the CI build. The Gitea workflow runs this on
|
# script/cibuild: run the CI build. It bootstraps first: a CI runner
|
||||||
# push. The Dockerfile runs every gate make check runs, as build steps,
|
# checks out and runs this and nothing else, and script/fmt-check runs
|
||||||
# so a successful build means the repo is green.
|
# the formatter on the host, which a pristine checkout cannot do.
|
||||||
#
|
# --no-cache for the same reason as script/docker: the gate phases the
|
||||||
# Without a fresh CHECK_EPOCH, a rebuild of an unchanged checkout serves
|
# final stage depends on are RUN steps, and a cached one is a check that
|
||||||
# the gate layers from cache and passes having run none of them. The
|
# did not run.
|
||||||
# process id goes in with the epoch so two runs started in the same
|
|
||||||
# second still differ.
|
|
||||||
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"
|
||||||
docker build --build-arg CHECK_EPOCH="$(date +%s)-$$" .
|
"$SCRIPT_DIR/bootstrap"
|
||||||
|
"$SCRIPT_DIR/check"
|
||||||
|
# The version and the tag each get their 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"
|
||||||
|
tag="$("$SCRIPT_DIR/projectname")"
|
||||||
|
docker build --no-cache \
|
||||||
|
--build-arg VERSION="$version" \
|
||||||
|
-t "$tag" .
|
||||||
}
|
}
|
||||||
|
|
||||||
main "$@"
|
main "$@"
|
||||||
|
|||||||
+14
-8
@@ -1,9 +1,8 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# script/docker: build the Docker image tagged with the project name.
|
# script/docker: build the Docker image tagged with the project name.
|
||||||
# The tag comes from script/projectname. CHECK_EPOCH is passed for the
|
# Identical in all repos; the tag comes from script/projectname.
|
||||||
# same reason script/cibuild passes it: without a fresh value an
|
# --no-cache because the gate phases the final stage depends on are RUN
|
||||||
# unchanged tree is served from cache and this exits 0 having run no
|
# steps, and a cached one is a check that did not run.
|
||||||
# gate.
|
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
||||||
@@ -11,10 +10,17 @@ ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
|
|||||||
|
|
||||||
main() {
|
main() {
|
||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
docker build \
|
# The version and the tag each get their own line: a failing
|
||||||
--build-arg CHECK_EPOCH="$(date +%s)-$$" \
|
# command substitution inside an argument does not trip `set -e`,
|
||||||
-t "$("$SCRIPT_DIR/projectname")" \
|
# 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"
|
||||||
|
tag="$("$SCRIPT_DIR/projectname")"
|
||||||
|
docker build --no-cache \
|
||||||
|
--build-arg VERSION="$version" \
|
||||||
|
-t "$tag" .
|
||||||
}
|
}
|
||||||
|
|
||||||
main "$@"
|
main "$@"
|
||||||
|
|||||||
+22
-12
@@ -1,22 +1,32 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# script/fmt: format all files (writes): the Go sources with gofmt, the
|
# script/fmt: format all files (writes).
|
||||||
# Markdown with prettier. prettier is never installed on the host: it
|
|
||||||
# runs from the Dockerfile's prettier stage with the repository mounted,
|
|
||||||
# as the calling user so the files it rewrites keep their owner. The tag
|
|
||||||
# makes each build replace the previous image instead of leaving another
|
|
||||||
# one behind.
|
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||||
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
|
|
||||||
|
# Must match the pin in script/bootstrap.
|
||||||
|
NODE_VERSION="22.17.0"
|
||||||
|
|
||||||
|
# script/bootstrap installs node and yarn under nvm and leaves neither
|
||||||
|
# on the PATH of the shell that called it, so resolve the pinned
|
||||||
|
# toolchain here the way bootstrap's own install step does. nvm is a
|
||||||
|
# bash script, hence the subshell.
|
||||||
|
run_yarn() {
|
||||||
|
if command -v yarn >/dev/null 2>&1; then
|
||||||
|
exec yarn "$@"
|
||||||
|
fi
|
||||||
|
if [ ! -s "$HOME/.nvm/nvm.sh" ]; then
|
||||||
|
echo "fmt: no yarn; run script/bootstrap first" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
exec bash -c '. "$HOME/.nvm/nvm.sh" && nvm use "$1" >/dev/null &&
|
||||||
|
shift && exec yarn "$@"' bash "$NODE_VERSION" "$@"
|
||||||
|
}
|
||||||
|
|
||||||
main() {
|
main() {
|
||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
gofmt -s -w .
|
gofmt -s -w .
|
||||||
image="$("$SCRIPT_DIR/projectname")-prettier"
|
run_yarn run prettier --write '**/*.md' --tab-width 4 --prose-wrap always
|
||||||
docker build -q --target prettier -t "$image" . >/dev/null
|
|
||||||
docker run --rm --user "$(id -u):$(id -g)" -v "$ROOT:/src" "$image" \
|
|
||||||
prettier --write '**/*.md' --tab-width 4 --prose-wrap always
|
|
||||||
}
|
}
|
||||||
|
|
||||||
main "$@"
|
main "$@"
|
||||||
|
|||||||
+26
-15
@@ -1,19 +1,35 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# script/fmt-check: check formatting (read-only). Same scope as
|
# script/fmt-check: check formatting (read-only).
|
||||||
# script/fmt, but fails instead of writing. gofmt and prettier both run
|
|
||||||
# every time and each reports its own failure, so the output says which
|
|
||||||
# one failed.
|
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||||
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
|
|
||||||
|
# Must match the pin in script/bootstrap.
|
||||||
|
NODE_VERSION="22.17.0"
|
||||||
|
|
||||||
|
# script/bootstrap installs node and yarn under nvm and leaves neither
|
||||||
|
# on the PATH of the shell that called it, so resolve the pinned
|
||||||
|
# toolchain here the way bootstrap's own install step does. nvm is a
|
||||||
|
# bash script, hence the subshell.
|
||||||
|
run_yarn() {
|
||||||
|
if command -v yarn >/dev/null 2>&1; then
|
||||||
|
exec yarn "$@"
|
||||||
|
fi
|
||||||
|
if [ ! -s "$HOME/.nvm/nvm.sh" ]; then
|
||||||
|
echo "fmt-check: no yarn; run script/bootstrap first" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
exec bash -c '. "$HOME/.nvm/nvm.sh" && nvm use "$1" >/dev/null &&
|
||||||
|
shift && exec yarn "$@"' bash "$NODE_VERSION" "$@"
|
||||||
|
}
|
||||||
|
|
||||||
main() {
|
main() {
|
||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
status=0
|
status=0
|
||||||
|
|
||||||
# Under set -e a bare assignment would end the script when gofmt
|
# gofmt and prettier both run every time, so the output names each
|
||||||
# fails (a Go file it cannot parse), and prettier would never run.
|
# one that fails. Under set -e a bare assignment would end the
|
||||||
|
# script when gofmt fails (a Go file it cannot parse).
|
||||||
if ! files="$(gofmt -s -l .)"; then
|
if ! files="$(gofmt -s -l .)"; then
|
||||||
echo "gofmt: failed; see its errors above" >&2
|
echo "gofmt: failed; see its errors above" >&2
|
||||||
status=1
|
status=1
|
||||||
@@ -24,14 +40,9 @@ main() {
|
|||||||
status=1
|
status=1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Same image as script/fmt; see there.
|
# run_yarn ends in exec; the subshell returns here afterwards.
|
||||||
image="$("$SCRIPT_DIR/projectname")-prettier"
|
(run_yarn run prettier --check '**/*.md' --tab-width 4 --prose-wrap always) ||
|
||||||
docker build -q --target prettier -t "$image" . >/dev/null
|
|
||||||
if ! docker run --rm -v "$ROOT:/src:ro" "$image" \
|
|
||||||
prettier --check '**/*.md' --tab-width 4 --prose-wrap always; then
|
|
||||||
echo "prettier: Markdown not formatted; run make fmt" >&2
|
|
||||||
status=1
|
status=1
|
||||||
fi
|
|
||||||
|
|
||||||
exit "$status"
|
exit "$status"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,15 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# script/install-precommit: install the git pre-commit hook that runs
|
# script/install-precommit: install the git pre-commit hook that runs
|
||||||
# script/precommit. Our own extension to scripts-to-rule-them-all.
|
# script/precommit. Our own extension to scripts-to-rule-them-all.
|
||||||
# Hooks are shared between the main checkout and all worktrees, so
|
|
||||||
# resolve the common git dir instead of assuming .git is a directory.
|
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||||
|
|
||||||
main() {
|
main() {
|
||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
hooks_dir="$(git rev-parse --git-common-dir)/hooks"
|
hook=".git/hooks/pre-commit"
|
||||||
mkdir -p "$hooks_dir"
|
printf '#!/bin/sh\nset -e\nscript/precommit\n' > .git/hooks/pre-commit
|
||||||
hook="$hooks_dir/pre-commit"
|
chmod +x .git/hooks/pre-commit
|
||||||
printf '#!/bin/sh\nset -e\nscript/precommit\n' > "$hook"
|
|
||||||
chmod +x "$hook"
|
|
||||||
echo "pre-commit hook installed: runs script/precommit"
|
echo "pre-commit hook installed: runs script/precommit"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+13
-18
@@ -1,28 +1,23 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# script/lint: run the linter. golangci-lint is never installed on a
|
# script/lint: run the linter. Linting is a phase of the Dockerfile and
|
||||||
# host: this builds Dockerfile.lint, which copies the repo into the
|
# this builds that phase alone; the linter is never installed or run on
|
||||||
# digest-pinned golangci-lint image and lints as a build step, so a
|
# a developer host, where a shared result cache and a host-global lock
|
||||||
# successful build is a clean lint. A cold cache needs the network to
|
# make its answer untrustworthy.
|
||||||
# pull the image and for `go mod download`; once warm this runs offline
|
|
||||||
# until go.mod or go.sum changes.
|
|
||||||
#
|
#
|
||||||
# Without a fresh CHECK_EPOCH docker serves the gate layers from cache
|
# The phase is not the last stage in the file, so it is built only when
|
||||||
# on an unchanged tree and this exits 0 having run no linter. The PID is
|
# --target names it. --no-cache because a cached lint layer is a lint
|
||||||
# in the value because two lint runs land inside the same second easily.
|
# that did not run. --output type=cacheonly writes no image, since
|
||||||
#
|
# nothing uses one.
|
||||||
# The image is never used, so --output=type=cacheonly writes none;
|
|
||||||
# without it every run leaves an untagged image behind.
|
|
||||||
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"
|
||||||
docker build \
|
docker build --no-cache \
|
||||||
--build-arg CHECK_EPOCH="$(date +%s)-$$" \
|
--target lint \
|
||||||
--output=type=cacheonly \
|
--output type=cacheonly .
|
||||||
-f Dockerfile.lint \
|
|
||||||
.
|
|
||||||
}
|
}
|
||||||
|
|
||||||
main "$@"
|
main "$@"
|
||||||
|
|||||||
+2
-2
@@ -1,13 +1,13 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# script/precommit: run by the git pre-commit hook; fails the commit if
|
# script/precommit: run by the git pre-commit hook; fails the commit if
|
||||||
# checks fail. Our own extension to scripts-to-rule-them-all. Go extra:
|
# checks fail. Our own extension to scripts-to-rule-them-all.
|
||||||
# go mod tidy must be a no-op before the checks run.
|
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
||||||
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
|
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
|
||||||
|
|
||||||
main() {
|
main() {
|
||||||
|
# Go extra: go mod tidy must be a no-op before the checks run.
|
||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
go mod tidy
|
go mod tidy
|
||||||
if ! git diff --exit-code -- go.mod go.sum; then
|
if ! git diff --exit-code -- go.mod go.sum; then
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# script/setup: set up the repo for development after a fresh clone:
|
# script/setup: set up the repo for development after a fresh clone:
|
||||||
# installs dependencies (script/bootstrap) and the git pre-commit hook.
|
# installs dependencies and the git pre-commit hook.
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
||||||
|
|||||||
+10
-8
@@ -1,17 +1,19 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# script/test: run the test suite. Reruns verbosely on failure so CI
|
# script/test: run the test suite. Testing is a phase of the Dockerfile
|
||||||
# logs show which test failed.
|
# 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, and --no-cache because a cached test layer is a test that did
|
||||||
|
# not run. --output type=cacheonly writes no image, since nothing uses one.
|
||||||
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"
|
||||||
go test -timeout 30s -cover ./... || {
|
docker build --no-cache \
|
||||||
echo "--- Rerunning with -v for details ---"
|
--target test \
|
||||||
go test -timeout 30s -v ./...
|
--output type=cacheonly .
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
main "$@"
|
main "$@"
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
# script/test-race: run the test suite under the race detector. Not part
|
|
||||||
# of script/check.
|
|
||||||
#
|
|
||||||
# The race detector needs cgo and a C compiler, which the host build
|
|
||||||
# never uses, so the tests run in a golang image that has gcc. The
|
|
||||||
# checkout is mounted read-only, so the docker daemon must be local. The
|
|
||||||
# container starts with empty caches every time: each run downloads the
|
|
||||||
# dependencies and compiles them with the detector, which needs the
|
|
||||||
# network and takes minutes.
|
|
||||||
#
|
|
||||||
# The tests run as the calling user, never as root: several of them make
|
|
||||||
# a file unreadable and expect reading it to fail, and root reads it
|
|
||||||
# anyway. When the caller is root they run as nobody, and then the
|
|
||||||
# checkout must be readable by other users. Neither user has a home
|
|
||||||
# directory in the image, so HOME is /tmp, where Go puts its build cache.
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
|
||||||
|
|
||||||
# golang:1.25-trixie, 2026-10-04. Debian rather than the Alpine image the
|
|
||||||
# Dockerfile builds with, because this one includes gcc.
|
|
||||||
IMAGE="golang@sha256:2c4c60ef415fbfa5e90300722293bef36c5e63fae17570ce18f580af933dbd73"
|
|
||||||
|
|
||||||
main() {
|
|
||||||
user="$(id -u):$(id -g)"
|
|
||||||
if [ "$(id -u)" -eq 0 ]; then
|
|
||||||
user=65534:65534
|
|
||||||
fi
|
|
||||||
docker run --rm \
|
|
||||||
--user "$user" \
|
|
||||||
--env HOME=/tmp \
|
|
||||||
--env CGO_ENABLED=1 \
|
|
||||||
--volume "$ROOT:/src:ro" \
|
|
||||||
--workdir /src \
|
|
||||||
"$IMAGE" \
|
|
||||||
go test -race -timeout 60s ./...
|
|
||||||
}
|
|
||||||
|
|
||||||
main "$@"
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
# script/verify-lint-image-pin: fail unless the golangci-lint image
|
|
||||||
# referenced by Dockerfile.lint and the one referenced by the main
|
|
||||||
# Dockerfile's lint stage are the same image at the same digest. Our own
|
|
||||||
# extension to scripts-to-rule-them-all, not one of its entrypoints; run
|
|
||||||
# as a gate in both files. Nothing else keeps the two pins in sync, and
|
|
||||||
# a bump applied to one alone would lint the same tree against different
|
|
||||||
# rulesets, both green.
|
|
||||||
#
|
|
||||||
# Do not hardcode the expected digest here: that is a third copy to keep
|
|
||||||
# in sync.
|
|
||||||
#
|
|
||||||
# A reference that cannot be read is a hard failure, not a skip: two
|
|
||||||
# empty strings compare equal.
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
|
||||||
|
|
||||||
LINT_DOCKERFILE="Dockerfile.lint"
|
|
||||||
MAIN_DOCKERFILE="Dockerfile"
|
|
||||||
|
|
||||||
# Echo the single golangci-lint image reference in the named Dockerfile.
|
|
||||||
# Scans every argument of every FROM instruction rather than assuming a
|
|
||||||
# field position, so `FROM --platform=... img AS stage` reads correctly.
|
|
||||||
# Exits non-zero, with a diagnosis, unless there is exactly one.
|
|
||||||
lint_image_ref() {
|
|
||||||
file="$1"
|
|
||||||
|
|
||||||
if [ ! -f "$file" ]; then
|
|
||||||
echo "verify-lint-image-pin: $file: not found" >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
refs="$(
|
|
||||||
awk '
|
|
||||||
toupper($1) == "FROM" {
|
|
||||||
for (i = 2; i <= NF; i++) {
|
|
||||||
if ($i ~ /^golangci\/golangci-lint[:@]/) {
|
|
||||||
print $i
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
' "$file"
|
|
||||||
)"
|
|
||||||
|
|
||||||
count="$(printf '%s' "$refs" | grep -c . || true)"
|
|
||||||
if [ "$count" -ne 1 ]; then
|
|
||||||
echo "verify-lint-image-pin: $file: expected exactly one" \
|
|
||||||
"golangci/golangci-lint FROM reference, found $count" >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
printf '%s\n' "$refs"
|
|
||||||
}
|
|
||||||
|
|
||||||
main() {
|
|
||||||
cd "$ROOT"
|
|
||||||
|
|
||||||
lint_ref="$(lint_image_ref "$LINT_DOCKERFILE")"
|
|
||||||
main_ref="$(lint_image_ref "$MAIN_DOCKERFILE")"
|
|
||||||
|
|
||||||
if [ "$lint_ref" != "$main_ref" ]; then
|
|
||||||
echo "verify-lint-image-pin: the linter image is pinned twice and" \
|
|
||||||
"the two pins disagree:" >&2
|
|
||||||
echo "verify-lint-image-pin: $LINT_DOCKERFILE: $lint_ref" >&2
|
|
||||||
echo "verify-lint-image-pin: $MAIN_DOCKERFILE: $main_ref" >&2
|
|
||||||
echo "verify-lint-image-pin: bump both FROM lines together so" \
|
|
||||||
"script/lint and the Dockerfile lint stage keep running the" \
|
|
||||||
"same linter" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "verify-lint-image-pin: $LINT_DOCKERFILE and $MAIN_DOCKERFILE" \
|
|
||||||
"agree on $lint_ref"
|
|
||||||
}
|
|
||||||
|
|
||||||
main "$@"
|
|
||||||
Reference in New Issue
Block a user