1 Commits
Author SHA1 Message Date
clawbot b5819282f3 Re-vendor the canonical files from sneak/prompts at dd4027b (closes #95)
check / check (push) Canceled after 0s
The shared files are the copies at sneak/prompts commit dd4027b, with this
repository's own entries kept after them. golangci-lint is v2.14.0 and
raises no findings. Lint and test are phases of the Dockerfile, built by
script/lint and script/test; the tests run under the race detector as
nobody, so Dockerfile.lint, script/verify-lint-image-pin and make
test-race are gone. Every docker build in script/ passes --no-cache in
place of the old cache-busting build argument, and script/cibuild runs
script/bootstrap and script/check before the image build.
.claude/settings.json is deleted.

Deviation: the workflow keeps fetch-depth: 0 for the tag-derived version.
Deviation: .gitignore keeps the scan database patterns.
Deviation: prettier still runs in Docker, not on the host.
Over the cap: make test takes about 60 seconds on this host.

Model: opus-5-5
2026-10-07 21:46:20 +00:00
9 changed files with 154 additions and 253 deletions
+36 -12
View File
@@ -1,6 +1,6 @@
# Lint phase, built alone by script/lint. The tools are invoked directly # Lint phase, built alone by script/lint. The tools are invoked directly
# rather than through `make lint`, which runs docker itself and so cannot # rather than through `make lint` or `make fmt-check`, which run docker
# run inside a build step. # themselves and so cannot run inside a build step.
# golangci/golangci-lint:v2.14.0, 2026-10-07 # golangci/golangci-lint:v2.14.0, 2026-10-07
FROM golangci/golangci-lint@sha256:ad862ba6b3798cbe0fd9fd7408d498fd74fbd2623a92406b2fd3898faf0bf98f AS lint FROM golangci/golangci-lint@sha256:ad862ba6b3798cbe0fd9fd7408d498fd74fbd2623a92406b2fd3898faf0bf98f AS lint
WORKDIR /src WORKDIR /src
@@ -8,9 +8,9 @@ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY . . COPY . .
# The gofmt half of `make fmt-check`. gofmt's output is assigned to a # The gofmt half of `make fmt-check`; the markdown stage is its prettier
# variable first so that its own exit status, as when it cannot parse a # half. gofmt's output is assigned to a variable first so that its own
# file, still fails the step. # exit status, as when it cannot parse a file, still fails the step.
RUN files="$(gofmt -s -l .)" && \ RUN 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; \
@@ -38,19 +38,43 @@ RUN go test -timeout 90s -race -cover ./... || \
{ echo "--- Rerunning with -v for details ---"; \ { echo "--- Rerunning with -v for details ---"; \
go test -timeout 90s -race -v ./...; exit 1; } go test -timeout 90s -race -v ./...; exit 1; }
# Build stage. Nothing is wanted from either phase above; the copies are # Prettier stage: the prettier that formats this repository's Markdown,
# what make BuildKit build them first, so this stage cannot run unless # never installed on a host. script/fmt and script/fmt-check build this
# lint and test passed. # 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
# Markdown stage: the Markdown half of `make fmt-check`, as a gate.
FROM prettier AS markdown
COPY . .
RUN prettier --check '**/*.md' --tab-width 4 --prose-wrap always
# Build stage. Nothing is wanted from the lint, test and markdown stages;
# the copies are what make BuildKit build them first, so this stage
# cannot run unless all three 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
COPY --from=lint /src/go.sum /dev/null COPY --from=lint /src/go.sum /dev/null
COPY --from=test /src/go.sum /dev/null COPY --from=test /src/go.sum /dev/null
RUN apk add --no-cache git make COPY --from=markdown /src/go.sum /dev/null
WORKDIR /src
# script/bootstrap installs the git and make this image lacks, and ends
# in `go mod download`.
COPY script/ script/
COPY go.mod go.sum ./
RUN script/bootstrap
# A tar-stream context keeps the sender's file owners, which git refuses. # A tar-stream context keeps the sender's file owners, which git refuses.
RUN git config --system --add safe.directory /src RUN git config --system --add safe.directory /src
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . . COPY . .
# The version stamped into the binary: the VERSION build argument when # The version stamped into the binary: the VERSION build argument when
+32 -27
View File
@@ -738,47 +738,50 @@ 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. An installed node is used as it is; otherwise node presence-checked only. `golangci-lint` and prettier are deliberately **not**
22.17.0 is installed through nvm, which comes from a release archive whose installed: they run in Docker (see `script/lint` and `script/fmt`) and never
sha256 the script checks. yarn 1.22.22 comes through corepack, and from a host install, so there is no host copy to drift from the pin. A missing
`yarn install --frozen-lockfile` installs the prettier that `package.json` and `docker` is warned about rather than installed or treated as fatal:
`yarn.lock` pin. `golangci-lint` is never installed: it runs in Docker (see `make build` works without it, and testing, linting, formatting and the image
`script/lint`). `docker` is not installed either; testing, linting and the build need it. Ends with `go mod download`.
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 under the race detector, with a 90-second - `script/test` — run the test suite under the race detector, with a 90-second
timeout and coverage enabled, rerunning verbosely on failure so the logs show timeout and coverage enabled, rerunning verbosely on failure so the logs show
which test failed. It builds the `Dockerfile`'s `test` phase alone and writes which test failed. It builds the `Dockerfile`'s `test` phase alone, tagged
no image. The race detector needs cgo and a C compiler, which the build never `sfdupes-test`. The race detector needs cgo and a C compiler, which the build
uses, so the phase starts from a digest-pinned Debian `golang` image, which never uses, so the phase starts from a digest-pinned Debian `golang` image,
has `gcc`. The tests run as `nobody`, because several of them make a file which has `gcc`. The tests run as `nobody`, because several of them make a
unreadable and root reads it anyway. file unreadable and root reads it anyway.
- `script/lint` — run the linter. It builds the `Dockerfile`'s `lint` phase - `script/lint` — run the linter. It builds the `Dockerfile`'s `lint` phase
alone and writes no image: in the digest-pinned `golangci/golangci-lint` alone, tagged `sfdupes-lint`: in the digest-pinned `golangci/golangci-lint`
image, the gofmt check, `golangci-lint config verify` and `golangci-lint run` image, the gofmt check, `golangci-lint config verify` and `golangci-lint run`
run as build steps, so a successful build is a clean lint. The linter is never run as build steps, so a successful build is a clean lint. The linter is never
run on the host, which makes a working `docker` the one prerequisite for run on the host, which makes a working `docker` the one prerequisite for
linting. linting.
- `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). Both run on the host, prettier through indents, prose wrapped at 80 columns). prettier is pinned by hash through
yarn at the version `yarn.lock` pins. When yarn is not on `PATH`, this loads `package.json` and `yarn.lock` and never installed on the host: this builds
the node 22.17.0 that `script/bootstrap` installed through nvm. the `Dockerfile`'s `prettier` stage, a digest-pinned node image into which
- `script/fmt-check` — the read-only counterpart of `script/fmt`: prints any `yarn install --frozen-lockfile` installs it, tagged `sfdupes-prettier`, and
unformatted file and exits non-zero instead of writing. gofmt and prettier runs that with the repository mounted, as the calling user. Needs `docker`,
both run every time, and each names itself when it fails. The `Dockerfile`'s and because of the mount, unlike `script/lint`, a local docker daemon.
`lint` phase runs the same gofmt check. - `script/fmt-check` — the read-only counterpart of `script/fmt`, with the
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` phase, 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. The first two need `docker`, the third what that order. Modifies nothing. Needs `docker`, because all three 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`, passing the output of `script/projectname`, passing the output of
`git describe --tags --always --dirty` (or `unknown` when that is empty) as `git describe --tags --always --dirty` (or `unknown` when that is empty) as
the `VERSION` build argument. The `Dockerfile`'s build stage depends on its the `VERSION` build argument. The `Dockerfile`'s build stage depends on its
`lint` and `test` phases, so this also runs the tests and the linter. `lint`, `test` and `markdown` stages, so this is also the check a developer or
reviewer runs by hand.
- `script/cibuild` — run `script/bootstrap`, then `script/check`, then build the - `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 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: successful run means every check passed. The tests and the linter run twice:
@@ -793,7 +796,9 @@ entrypoints are:
Every `docker build` in `script/` passes `--no-cache`. On an unchanged tree Every `docker build` in `script/` passes `--no-cache`. On an unchanged tree
Docker would serve the gate steps from its cache, and the build would pass Docker would serve the gate steps from its cache, and the build would pass
having run no test and no linter. Every run therefore downloads the Go modules having run no test and no linter. Every run therefore downloads the Go modules
again and needs the network. again and needs the network. `script/test` and `script/lint` send the daemon
only a build context, so they work against a remote docker daemon; `script/fmt`
and `script/fmt-check` mount the repository and need a local one.
## Build ## Build
@@ -810,10 +815,10 @@ compile recipe:
- `make lint` — run `golangci-lint` with the repo config and the gofmt check, in - `make lint` — run `golangci-lint` with the repo config and the gofmt check, in
Docker (see `script/lint`); requires `docker`. 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, on the host; requires `go` and what verify formatting without writing; requires `docker`, for prettier (see
`make bootstrap` installs (see `script/fmt`). `script/fmt`).
- `make check` — `test`, `lint`, and `fmt-check`; modifies nothing. Requires - `make check` — `test`, `lint`, and `fmt-check`; modifies nothing. Requires
`docker` and what `make bootstrap` installs. `docker`.
- `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.
+25 -58
View File
@@ -1,6 +1,6 @@
--- ---
title: Repository Policies title: Repository Policies
last_modified: 2026-10-07 last_modified: 2026-10-04
--- ---
This document covers repository structure, tooling, and workflow standards. Code This document covers repository structure, tooling, and workflow standards. Code
@@ -118,8 +118,8 @@ style conventions are in separate documents:
and nothing else: and nothing else:
```sh ```sh
docker build --no-cache --target lint --output type=cacheonly . docker build --no-cache --target lint -t "$(script/projectname)-lint" .
docker build --no-cache --target test --output type=cacheonly . docker build --no-cache --target test -t "$(script/projectname)-test" .
``` ```
**A stage that is not the last one in the file is built only when the final **A stage that is not the last one in the file is built only when the final
@@ -129,15 +129,10 @@ style conventions are in separate documents:
plain `docker build .` builds the last stage alone and exits 0 having linted plain `docker build .` builds the last stage alone and exits 0 having linted
and tested nothing. and tested nothing.
**The gate builds write no image.** With `--output type=cacheonly` the phase **Every `docker build` in `script/` is tagged**, here and in
runs and a failing step fails the build, but the result is not exported. `script/cibuild` and `script/docker`. An untagged build leaves a dangling
Nothing uses those images, and writing one out is slow: a Go test phase's image behind on every invocation, on every developer host and every CI
image holds the toolchain and every compiled package. A build given neither runner; a tagged one replaces the previous image.
`--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`, Inside a phase the tool is invoked directly — `golangci-lint`, `go test`,
`eslint`, `prettier` — never through `make lint` or `script/test`, which are `eslint`, `prettier` — never through `make lint` or `script/test`, which are
@@ -283,39 +278,20 @@ style conventions are in separate documents:
`.git` and the version still comes out empty, `dev` or `unknown`. A plain `.git` and the version still comes out empty, `dev` or `unknown`. A plain
`docker build .` with no build arguments must succeed; a Dockerfile that `docker build .` with no build arguments must succeed; a Dockerfile that
refuses an empty build argument drops that refusal and keeps the argument. 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` on push, and checks out the repo as its only other step, runs `script/cibuild` on push, and checks out the repo as its only other step.
with `persist-credentials: false`: `script/cibuild` needs no token, and That script bootstraps, runs the gate phases, and then builds the image, so a
without it the checkout leaves the job's token in `.git/config` for every successful run means every check passed; a bare `docker build .` does not
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 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 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 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 from a run of its own gates rather than from a cache entry. A separate
sets `timeout-minutes: 20`, so a hung build frees the shared runner after 20 workflow limited to `main` by a `branches` list under `on: push` cannot be
minutes. That allows for the three Docker builds described above (the test checked by review: to try a change to it, add the feature branch to that list
phase, the lint phase, then the image), each held to the 5-minute Docker build and push, then remove the branch from the list again before merging. Keep any
limit below, plus the bootstrap. A separate workflow limited to `main` by a job in it that publishes behind `if: github.ref_name == 'main'`, so the run
`branches` list under `on: push` cannot be checked by review: to try a change from the feature branch publishes nothing.
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
@@ -408,12 +384,9 @@ style conventions are in separate documents:
- `.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`, `*~`), in-repo agent scratch directories (`.claude/`), editor files (`.swp`, `*~`), in-repo agent scratch directories (`.claude/`),
`node_modules/`, and the repo's own build outputs. Fetch the standard language build artifacts, and `node_modules/`. Fetch the standard `.gitignore`
`.gitignore` from from `https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when setting up setting up a new repo. These patterns are written to `.gitignore`'s own
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 semantics, in which an unanchored pattern already matches at every depth; they
are not a `.dockerignore` and must not be transplanted into one unmodified. are not a `.dockerignore` and must not be transplanted into one unmodified.
@@ -461,15 +434,13 @@ style conventions are in separate documents:
byte-identically across repos: byte-identically across repos:
```sh ```sh
# The version and the tag each get their own line: a failing command # Own line: a failing command substitution inside an argument does not
# substitution inside an argument does not trip `set -e`, so the inline # trip `set -e`, so the inline form degrades to an empty constant.
# form degrades to an empty constant.
version="$(git describe --tags --always --dirty 2>/dev/null || true)" version="$(git describe --tags --always --dirty 2>/dev/null || true)"
[ -n "$version" ] || version="unknown" [ -n "$version" ] || version="unknown"
tag="$(script/projectname)"
docker build --no-cache \ docker build --no-cache \
--build-arg VERSION="$version" \ --build-arg VERSION="$version" \
-t "$tag" . -t "$(script/projectname)" .
``` ```
`--always` makes an untagged repo yield an abbreviated commit hash rather `--always` makes an untagged repo yield an abbreviated commit hash rather
@@ -481,8 +452,8 @@ style conventions are in separate documents:
there because `ARG` is stage-scoped; passing `VERSION` to a repo whose 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 Dockerfile declares no such `ARG` is ignored and costs nothing, which is why
the scripts stay byte-identical. One consequence for CI: the standard the scripts stay byte-identical. One consequence for CI: the standard
checkout action clones shallow and fetches no tags, so the canonical checkout action clones shallow and fetches no tags, so a repo that embeds a
`.gitea/workflows/check.yml` sets `fetch-depth: 0` on its checkout step. tag-derived version must set `fetch-depth: 0` on its checkout step.
- **Verify `.dockerignore` by enumerating the image, not by reading the - **Verify `.dockerignore` by enumerating the image, not by reading the
patterns.** Plant files at the root _and_ at least two directories deep, build patterns.** Plant files at the root _and_ at least two directories deep, build
@@ -665,11 +636,7 @@ 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: the standard file from settings.
`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`, `AGENTS.md`, `Makefile`, only project-level config files (`README.md`, `AGENTS.md`, `Makefile`,
+11 -18
View File
@@ -29,16 +29,12 @@
# Completed Steps # Completed Steps
- re-vendor the canonical files from `sneak/prompts` commit `dd4027b`, with - re-vendor the canonical files from `sneak/prompts` commit `dd4027b`:
`script/lint`, `script/test` and `REPO_POLICIES.md` from its `next` at golangci-lint v2.14.0; lint and test are phases of the `Dockerfile`, and
`c55a0cb`: golangci-lint v2.14.0; lint and test are phases of the `Dockerfile` `make test` runs the suite under the race detector, so `Dockerfile.lint`,
that write no image, and `make test` runs the suite under the race detector, `script/verify-lint-image-pin` and `make test-race` are gone; every
so `Dockerfile.lint`, `script/verify-lint-image-pin` and `make test-race` are `docker build` in `script/` passes `--no-cache`; `.claude/settings.json` is
gone; every `docker build` in `script/` passes `--no-cache`; prettier runs on deleted (2026-10-07, https://git.eeqj.de/sneak/sfdupes/issues/95)
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; `.claude/settings.json` is deleted (2026-10-08,
https://git.eeqj.de/sneak/sfdupes/issues/95)
- cut the narration from `TODO.md` Completed Steps and from the comments in - cut the narration from `TODO.md` Completed Steps and from the comments in
`script/` and both Dockerfiles; §Workflow now branches from and merges to `script/` and both Dockerfiles; §Workflow now branches from and merges to
@@ -48,15 +44,12 @@
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)
- a bare `docker build .` failed, naming `script/cibuild` and `script/docker`, - a bare `docker build .` fails with a message naming `script/cibuild` and
rather than serve the gates from cache (2026-10-04, `script/docker` instead of serving the gates from cache (2026-10-04,
https://git.eeqj.de/sneak/sfdupes/issues/39). Since https://git.eeqj.de/sneak/sfdupes/issues/39)
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, and CI checks - `make fmt` and `make fmt-check` run prettier over all Markdown, in Docker, and
it; all Markdown reformatted (2026-10-04, CI checks 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
+16 -84
View File
@@ -1,22 +1,13 @@
#!/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: every install is guarded by a check so already # this repo. Idempotent; assumes nothing is present (not git, make, or
# installed tools are skipped. Base tooling comes from nix, apt, brew, # go). Base tooling comes from nix, apt, brew, or apk (detected in that
# or apk (detected in that order); assumes nothing is present. Node is # order). golangci-lint and prettier are never installed: they run via
# used directly if installed; otherwise it is installed at a pinned # docker only (script/lint, script/fmt, script/fmt-check).
# 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=""
@@ -49,7 +40,6 @@ 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
@@ -65,82 +55,24 @@ 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"
if missing make; then pkg_install gnumake make make make; fi # 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 git; then pkg_install git git git git; fi
# Go builds the binary and runs gofmt. Presence is the whole check: if missing make; then pkg_install gnumake make make make; fi
# 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
ensure_node # Warn, do not fail: only the targets named below, and the
ensure_yarn # pre-commit hook, need docker.
install_js_deps if missing docker; then
echo "bootstrap: WARNING: docker not found; make test, make lint," >&2
echo "bootstrap: make fmt, make fmt-check, make check and" >&2
echo "bootstrap: make docker require it." >&2
fi
go mod download go mod download
echo "bootstrap complete" echo "bootstrap complete"
+13 -22
View File
@@ -1,32 +1,23 @@
#!/bin/sh #!/bin/sh
# script/fmt: format all files (writes). # script/fmt: format all files (writes): the Go sources with gofmt, the
# 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
# build passes --no-cache, as every docker build in script/ does. The tag
# makes each build replace the previous image instead of leaving another
# one behind.
set -eu set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" SCRIPT_DIR="$(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 .
run_yarn run prettier --write '**/*.md' --tab-width 4 --prose-wrap always image="$("$SCRIPT_DIR/projectname")-prettier"
docker build -q --no-cache --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 "$@"
+15 -26
View File
@@ -1,35 +1,19 @@
#!/bin/sh #!/bin/sh
# script/fmt-check: check formatting (read-only). # script/fmt-check: check formatting (read-only). Same scope as
# 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
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" SCRIPT_DIR="$(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
# gofmt and prettier both run every time, so the output names each # Under set -e a bare assignment would end the script when gofmt
# one that fails. Under set -e a bare assignment would end the # fails (a Go file it cannot parse), and prettier would never run.
# 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
@@ -40,9 +24,14 @@ main() {
status=1 status=1
fi fi
# run_yarn ends in exec; the subshell returns here afterwards. # Same image as script/fmt; see there.
(run_yarn run prettier --check '**/*.md' --tab-width 4 --prose-wrap always) || image="$("$SCRIPT_DIR/projectname")-prettier"
docker build -q --no-cache --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"
} }
+3 -3
View File
@@ -6,8 +6,8 @@
# #
# The phase is not the last stage in the file, so it is built only when # The phase is not the last stage in the file, so it is built only when
# --target names it. --no-cache because a cached lint layer is a lint # --target names it. --no-cache because a cached lint layer is a lint
# that did not run. --output type=cacheonly writes no image, since # that did not run. The tag makes each build replace the previous image
# nothing uses one. # instead of leaving a dangling one behind.
set -eu set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
@@ -17,7 +17,7 @@ main() {
cd "$ROOT" cd "$ROOT"
docker build --no-cache \ docker build --no-cache \
--target lint \ --target lint \
--output type=cacheonly . -t "$("$SCRIPT_DIR/projectname")-lint" .
} }
main "$@" main "$@"
+3 -3
View File
@@ -2,8 +2,8 @@
# script/test: run the test suite. Testing is a phase of the Dockerfile # script/test: run the test suite. Testing is a phase of the Dockerfile
# and this builds that phase alone, on the same terms as script/lint: # 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 # --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 # named, --no-cache because a cached test layer is a test that did not
# not run. --output type=cacheonly writes no image, since nothing uses one. # run, and a tag so each build replaces the previous image.
set -eu set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
@@ -13,7 +13,7 @@ main() {
cd "$ROOT" cd "$ROOT"
docker build --no-cache \ docker build --no-cache \
--target test \ --target test \
--output type=cacheonly . -t "$("$SCRIPT_DIR/projectname")-test" .
} }
main "$@" main "$@"