Adopt the scripts-to-rule-them-all layout and the canonical Docker gates
check / check (push) Waiting to run

script/ holds the entrypoints and the Makefile targets shim to them.
Linting and testing are phases of the Dockerfile, built alone by
script/lint and script/test with a fresh CACHEBUST value and cache
mounts for Go's build cache and golangci-lint's; the build stage
depends on both, and the runtime stage ships the binary and the source
without .git. Formatting stays on the host in script/fmt and
script/fmt-check (gofmt and the prettier pinned in yarn.lock).
script/cibuild bootstraps, runs script/check and builds the tagged
image, and the Gitea workflow calls it. script/precommit refuses a
commit whose go.mod or go.sum is not tidy.

cgo stays off, so the test phase runs on the alpine Go image without
-race; that is the one departure from the canonical Go Dockerfile.

Re-vendored from the prompts repository's next branch: REPO_POLICIES.md
(2026-11-04), .golangci.yml (byte-identical, so the local
exhaustruct_v5 addition is gone now that canonical carries it),
.gitea/workflows/check.yml, .editorconfig, .gitignore and .dockerignore,
the last two followed by this repository's own entries. AGENTS.md holds
the repository-specific guidance for agents. The README gains the
Entrypoints section and describes the new gates.
This commit is contained in:
2026-11-04 07:31:59 -08:00
parent 538eca9f5d
commit 7feb212d04
22 changed files with 1492 additions and 447 deletions
+79 -8
View File
@@ -1,9 +1,80 @@
.git
node_modules
.DS_Store
# .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
# 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
# password in a remote URL or the token the CI checkout step stores there.
# 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
# Environment files. `*.env` covers bare `.env` and the `prod.env`
# convention. Re-include a committed template with a negation if the
# build needs one: `!docs/example.env`.
**/*.[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 own host-built artifacts, anchored.
/sutil
*.test
*.out
*.log
*.prof
coverage.*
**/*.test
**/*.out
**/*.log
**/*.prof
**/coverage.*
+6
View File
@@ -10,3 +10,9 @@ insert_final_newline = true
[Makefile]
indent_style = tab
[*.go]
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.
+12 -1
View File
@@ -1,9 +1,20 @@
name: check
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:
check:
runs-on: ubuntu-latest
# Free the shared runner from a hung build.
timeout-minutes: 20
steps:
# actions/checkout v4.2.2, 2026-02-22
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
- run: docker build .
# script/cibuild needs no token, so none is left in .git/config.
with:
persist-credentials: false
# All history and tags, so git describe finds the version tag.
fetch-depth: 0
- run: script/cibuild
+39 -7
View File
@@ -11,17 +11,49 @@ Thumbs.db
.vscode/
*.sublime-*
# Node (prettier for markdown formatting)
# 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_modules/
# Environment / secrets
.env
.env.*
*.pem
*.key
# Secrets. Unanchored like every entry above, so each matches at every
# depth. Matching is case-sensitive on Linux, so names use character
# ranges rather than a lowercase form that misses `Server.Key`.
# Go build and test 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/`.
# The sutil binary that `make build` writes at the root.
/sutil
# Go test and profiling outputs.
*.test
*.out
*.log
+33 -10
View File
@@ -3,6 +3,9 @@ version: "2"
# Config schema uses the golangci-lint v2 layout (settings live under
# linters.settings, not top-level linters-settings) so that the
# thresholds below are actually applied by golangci-lint >= v2.
#
# This file has no formatters section, so golangci-lint run does not run
# gofmt. A repository's script/fmt-check runs it, on the host.
run:
timeout: 5m
@@ -16,13 +19,13 @@ linters:
- gomodguard_v2
disable:
# Genuinely incompatible with project patterns
- exhaustruct # Requires all struct fields
- exhaustruct_v5 # Same linter, newer major version (since v2.13.0)
- exhaustruct_v5 # Requires all struct fields
- godot # Requires comments to end with periods
- wrapcheck # Too verbose for internal packages
- varnamelen # Short names like db, id are idiomatic Go
# Deprecated: the warning is attached to the old name, so it is
# silenced by disabling that name, not by enabling the successor.
- exhaustruct # Deprecated, replaced by exhaustruct_v5
- wsl # Deprecated, replaced by wsl_v5
- gomodguard # Deprecated, replaced by gomodguard_v2
settings:
@@ -40,7 +43,10 @@ linters:
# test-support package exists to hand a test privileges the program
# itself must never have, so a file that is not a test must not import
# one. Test files, and the files inside a package whose directory name
# ends in `test`, are where that code belongs, and are exempt.
# ends in `test`, are where that code belongs, and are exempt. A package
# in a subdirectory of that directory is not exempt. `${base-path}` is
# the directory holding this file, so the name of the directory a
# repository is cloned into does not count.
#
# The deny list below is the one part of this file a repository is
# expected to extend, and the only part it may. depguard matches an
@@ -55,24 +61,25 @@ linters:
files:
- "$all"
- "!$test"
- "!**/*test/**"
- "!${base-path}**/*test/*"
deny:
- pkg: net/http/httptest
desc: >-
Test-support code belongs in test files and in packages whose
directory name ends in test, not in the shipped binary.
# Only decisions already recorded in the Go package defaults are
# listed here. Every entry matches the module path exactly.
# Each entry names a module not to use and what to use instead. Every
# entry matches the module path exactly.
gomodguard_v2:
blocked:
- module: github.com/rs/zerolog
recommendations:
- log/slog
reason: "Structured logging is stdlib log/slog."
# One entry per pre-fork module path, because the later releases
# are separate paths. A prefix match would be shorter but would
# also reach github.com/go-redis/redismock, the test double for
# the successor these entries recommend.
# These entries cover only the github.com/go-redis/redis module paths,
# one entry per path, because the later releases are separate paths.
# A prefix match would be shorter but would also reach
# github.com/go-redis/redismock/v9, the test double for the successor
# these entries recommend.
- module: github.com/go-redis/redis
recommendations:
- github.com/redis/go-redis/v9
@@ -85,6 +92,10 @@ linters:
recommendations:
- github.com/redis/go-redis/v9
reason: "Pre-fork module; use the maintained go-redis v9."
- module: github.com/go-redis/redis/v9
recommendations:
- github.com/redis/go-redis/v9
reason: "Pre-fork module; use the maintained go-redis v9."
- module: github.com/sergi/go-diff
recommendations:
- github.com/aymanbagabas/go-udiff
@@ -93,7 +104,19 @@ linters:
recommendations:
- github.com/aymanbagabas/go-udiff
reason: "Unmaintained fork; use go-udiff."
exclusions:
rules:
# homoicon keeps a local copy of sobek, its JavaScript engine, as sneak
# decided in
# https://git.eeqj.de/sneak/homoicon/issues/1759#issuecomment-153261.
# This is an exclusion because gomoddirectives does not check its
# replace-allow-list for a replace that points at a local path.
- path: ^go\.mod$
linters:
- gomoddirectives
text: '^local replacement are not allowed: github\.com/grafana/sobek$'
issues:
max-issues-per-linter: 0
max-same-issues: 0
uniq-by-line: false
+19
View File
@@ -0,0 +1,19 @@
# AGENTS.md
Guidance for coding agents working in this repository. The repository policies
are in `REPO_POLICIES.md`, vendored from the `prompts` repository; read that
first. Everything below is specific to this repository, and the README's Design
section explains each point.
- `pkg/` holds only packages designed to be imported by other programs. Anything
that exists to support `sutil` lives under `internal/`.
- `sutil` is not a command-line front end for the packages in `pkg/`. It holds
only the utilities the author wants available everywhere; a command is added
because a tool is wanted, not because a package exists.
- `pkg/cbk` derives crypto_box keypairs from BIP39 mnemonics with BIP85 and does
nothing else. BIP39 passphrases are deliberately unsupported.
- cgo stays off: builds are static, and the test phase runs without `-race`.
- Version tags are created by the author alone, when the author decides. Do not
propose, schedule, or create tags.
- `.golangci.yml` is byte-identical to the canonical copy and is not edited
here; changes go to the `prompts` repository.
+75 -36
View File
@@ -1,56 +1,95 @@
# The checks behind `make check` are split across independent stages so that
# formatting and lint failures surface in seconds, before any compilation:
# In every stage ARG CACHEBUST comes right after the steps that install
# system packages and download Go's modules, or right after FROM in a
# stage with neither, and every step that lints, tests or compiles
# comes after it. script/ passes a new value on every build, which
# reruns every RUN step after it. Docker keeps the layers above it
# until the base image, their own lines or go.mod and go.sum change.
# The steps that compile mount Go's build cache, and golangci-lint run
# also mounts golangci-lint's; both survive from build to build.
#
# lint - gofmt and golangci-lint (make fmt-check-go, make lint)
# docs - prettier check of all markdown (make fmt-check-md)
# builder - tests (make test) and the release build
#
# The builder stage declares explicit dependencies on lint and docs so
# BuildKit cannot finish the build unless every check passed.
# This repository keeps cgo off, so its test phase departs from the
# canonical Go Dockerfile in REPO_POLICIES.md in one way: it runs on the
# alpine Go image without -race, which needs a C toolchain. Everything
# else follows the canonical file.
# Lint stage: fast feedback on Go formatting and lint issues
# golangci/golangci-lint:v2.15.0, 2026-10-16 (keep in sync with Makefile)
# Lint phase
# golangci/golangci-lint:v2.15.0, 2026-10-16
FROM golangci/golangci-lint@sha256:fbfcfd12a6f8d7f1b97b3362ca80fc2a244d46863148c73cb88e7793af96c481 AS lint
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
ARG CACHEBUST
COPY . .
RUN make fmt-check-go
RUN make lint
RUN --mount=type=cache,target=/root/.cache/go-build \
--mount=type=cache,target=/root/.cache/golangci-lint \
golangci-lint run --config .golangci.yml ./...
# Docs stage: markdown formatting check with the pinned prettier
# node:24-alpine, 2026-10-28
FROM node@sha256:8a32c8cf649a49915a66e81e7a609223c708d0adabf288ee3f4077d765542f85 AS docs
RUN apk add --no-cache make
WORKDIR /src
COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile
COPY . .
RUN make fmt-check-md
# Build stage
# Test phase. -count=1 because the mounted cache also holds test
# results, which go test would otherwise replay. A first run that timed
# out gets no rerun, because a rerun would only wait for the same hang.
# golang:1.26-alpine, 2026-10-27
FROM golang@sha256:a2012b4a1d1f9f7e9f44e0d39b805a901e856b1dd80b4e6ee4338db547050308 AS builder
RUN apk add --no-cache make
FROM golang@sha256:a2012b4a1d1f9f7e9f44e0d39b805a901e856b1dd80b4e6ee4338db547050308 AS test
ENV CGO_ENABLED=0
WORKDIR /src
# Force BuildKit to run the lint and docs stages before proceeding
COPY --from=lint /src/go.sum /dev/null
COPY --from=docs /src/package.json /dev/null
COPY go.mod go.sum ./
RUN go mod download
ARG CACHEBUST
COPY . .
RUN make test
RUN --mount=type=cache,target=/root/.cache/go-build \
status=0; \
output=$(go test -count=1 -timeout 90s -cover ./... 2>&1) || status=$?; \
printf '%s\n' "$output"; \
test "$status" -eq 0 && exit 0; \
case "$output" in \
*"panic: test timed out"*|*"*** Test killed"*) exit 1 ;; \
esac; \
echo "--- Rerunning with -v for details ---"; \
go test -count=1 -timeout 90s -v ./...; \
exit 1
ARG VERSION=dev
RUN CGO_ENABLED=0 go build -trimpath \
-ldflags="-s -w -X main.Version=${VERSION}" \
-o /sutil ./cmd/sutil
# Build stage. Nothing is wanted from either phase above; the copies
# are what make BuildKit build them first, so nothing after them can
# run unless lint and test passed.
# golang:1.26-alpine, 2026-10-27
FROM golang@sha256:a2012b4a1d1f9f7e9f44e0d39b805a901e856b1dd80b4e6ee4338db547050308 AS builder
# git comes from the base image's own distribution package index, which
# is trusted, so it needs no version and no hash.
RUN apk add --no-cache git
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
ARG CACHEBUST
COPY --from=lint /src/go.sum /dev/null
COPY --from=test /src/go.sum /dev/null
# A tar-stream context keeps the sender's file owners, which git refuses.
RUN git config --system --add safe.directory /src
COPY . .
# Runtime stage: the binary plus the full source it was built from
# 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 --mount=type=cache,target=/root/.cache/go-build \
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 /sutil ./cmd/sutil
# The runtime stage ships the source; the repository history is not
# part of it.
RUN rm -rf /src/.git
# Runtime stage, and the last one: the binary plus the source it was
# built from.
# alpine:3.24, 2026-10-23
FROM alpine@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a
ARG CACHEBUST
COPY --from=builder /sutil /usr/local/bin/sutil
COPY --from=builder /src /usr/local/src/x
ENTRYPOINT ["sutil"]
+38 -95
View File
@@ -1,30 +1,45 @@
# sneak.berlin/go/x
#
# This Makefile is the single source of truth for how this repo is built,
# tested, linted, and formatted. Always use these targets rather than
# invoking the underlying tools directly.
.PHONY: bootstrap setup test lint fmt fmt-check check docker hooks \
build install tidy clean
.PHONY: all build install test lint fmt fmt-go fmt-md fmt-check \
fmt-check-go fmt-check-md check docker hooks tidy setup clean
# Makefile targets are thin shims; the implementations live in script/
# per the scripts-to-rule-them-all pattern (see the Entrypoints section
# of README.md). The targets after the shims are this repo's own.
bootstrap:
@script/bootstrap
setup:
@script/setup
test:
@script/test
lint:
@script/lint
fmt:
@script/fmt
fmt-check:
@script/fmt-check
check:
@script/check
docker:
@script/docker
hooks:
@script/install-precommit
# This repository's own targets.
BINARY := sutil
VERSION := $(shell git describe --always --dirty 2>/dev/null || echo dev)
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo unknown)
GOLDFLAGS := -X main.Version=$(VERSION)
GO_DIRS := cmd internal pkg
# golangci/golangci-lint:v2.15.0, 2026-10-16 (keep in sync with Dockerfile)
GOLANGCI_LINT_IMAGE := golangci/golangci-lint@sha256:fbfcfd12a6f8d7f1b97b3362ca80fc2a244d46863148c73cb88e7793af96c481
# flags are repeated here (also in .prettierrc) so this Makefile works
# standalone when copied as a template
PRETTIER := yarn run --silent prettier
PRETTIER_FLAGS := --tab-width 4 --prose-wrap always
MARKDOWN_GLOB := '**/*.md'
all: build
# cgo stays off everywhere: builds are static and the tests do not use
# -race, which would require a C toolchain.
# cgo stays off everywhere: builds are static, and the test phase of the
# Dockerfile runs without -race, which would need a C toolchain.
export CGO_ENABLED := 0
# Build the sutil binary in the repo root with the git version embedded.
@@ -35,81 +50,9 @@ build:
install:
go install -trimpath -ldflags "$(GOLDFLAGS)" ./cmd/$(BINARY)
# Run the tests quietly; on failure rerun verbosely for diagnostics and
# still fail.
test:
@go test -timeout 30s -cover ./... || \
{ echo "--- Rerunning with -v for details ---"; \
go test -timeout 30s -v ./...; exit 1; }
# Lint with the pinned golangci-lint image when docker is available so local
# results match CI exactly. Inside the Docker build (where there is no
# docker) the image's own golangci-lint binary is used. The host module
# cache is shared read-only; the linter and build caches live in named
# volumes so repeated runs are fast.
#
# .golangci.yml is verified against the linter's schema first: golangci-lint
# otherwise ignores keys it does not know and silently runs with defaults.
LINT_COMMAND := golangci-lint config verify && golangci-lint run ./...
lint:
@if command -v docker >/dev/null 2>&1; then \
docker run --rm \
-v "$(CURDIR)":/src \
-v "$$(go env GOMODCACHE)":/go/pkg/mod:ro \
-v sutil-golangci-lint-cache:/root/.cache/golangci-lint \
-v sutil-go-build-cache:/root/.cache/go-build \
-w /src $(GOLANGCI_LINT_IMAGE) sh -c '$(LINT_COMMAND)'; \
else \
$(LINT_COMMAND); \
fi
# Format everything (writes files).
fmt: fmt-go fmt-md
fmt-go:
gofmt -w $(GO_DIRS)
fmt-md:
$(PRETTIER) --write $(MARKDOWN_GLOB) $(PRETTIER_FLAGS)
# Check formatting without writing anything.
fmt-check: fmt-check-go fmt-check-md
fmt-check-go:
@unformatted="$$(gofmt -l $(GO_DIRS))"; \
if [ -n "$$unformatted" ]; then \
echo "gofmt: the following files need formatting:"; \
echo "$$unformatted"; \
exit 1; \
fi
fmt-check-md:
$(PRETTIER) --check $(MARKDOWN_GLOB) $(PRETTIER_FLAGS)
# Everything CI gates on. Must not modify any files.
check: test lint fmt-check
# Build the Docker image; the Dockerfile runs the same checks as `make check`.
docker:
docker build -t $(BINARY) .
# Install the pre-commit hook.
hooks:
@printf '#!/bin/sh\nset -e\n' > .git/hooks/pre-commit
@if [ -f go.mod ]; then \
printf 'go mod tidy\ngo fmt ./...\ngit diff --exit-code -- go.mod go.sum || { echo "go mod tidy changed files; please stage and retry"; exit 1; }\n' >> .git/hooks/pre-commit; \
fi
@printf 'make check\n' >> .git/hooks/pre-commit
@chmod +x .git/hooks/pre-commit
# Tidy go.mod and go.sum.
# Tidy go.mod and go.sum; script/precommit refuses a commit that needs it.
tidy:
go mod tidy
# One-time developer setup: install the pinned prettier for markdown checks.
setup:
yarn install --frozen-lockfile
clean:
rm -f $(BINARY)
+57 -21
View File
@@ -217,38 +217,74 @@ added because a tool is wanted, not because a package exists.
### Build, test, and CI
The `Makefile` is the single entry point: `make test` (30-second timeout, quiet
unless something fails), `make lint`, `make fmt` and `make fmt-check` (gofmt for
Go, prettier for markdown), `make check` for all three, `make build`,
`make install`, `make docker`, `make hooks`, `make tidy`, and `make setup` to
install the pinned prettier.
The gates live in the `Dockerfile`, in the canonical Go layout that
`REPO_POLICIES.md` prescribes: a `lint` phase on the hash-pinned golangci-lint
image and a `test` phase on the Go image. `script/lint` and `script/test` each
build one phase alone, with a fresh `CACHEBUST` value so the check step runs on
every build instead of being served from Docker's cache, and write no image. The
build stage depends on both phases, so the image cannot be built unless they
pass. Go's build cache and golangci-lint's cache are mounted into the phases and
survive between builds. Formatting is the one check that runs on the host:
`script/fmt-check` runs gofmt and the prettier pinned in `yarn.lock`.
`script/check` runs all three. `script/cibuild`, which the Gitea workflow calls,
bootstraps, runs `script/check`, and then builds the tagged image with the
version from `git describe`.
`make lint` runs golangci-lint inside the same hash-pinned Docker image the CI
build uses, so local results match CI exactly, and first verifies
`.golangci.yml` against the linter's schema, because golangci-lint otherwise
ignores keys it does not know and silently runs with defaults. The `Dockerfile`
splits `make check` across stages: a lint stage and a markdown-check stage fail
fast in seconds, and the build stage, which runs the tests and compiles `sutil`,
depends on both. The runtime image carries the binary and the source it was
built from. Every external reference, from base images to the GitHub Action in
Every external reference, from base images to the GitHub Action in
`.gitea/workflows/check.yml`, is pinned by content hash with a version and date
comment. `.golangci.yml` is this repo's copy of the standard configuration, kept
in the v2 layout with `exhaustruct_v5` and the deprecated `gomodguard` disabled.
cgo stays off: the Makefile exports `CGO_ENABLED=0` for every build and test, so
binaries are static and no C toolchain is needed anywhere.
comment. `.golangci.yml` is byte-identical to the canonical copy in the
`prompts` repository and is never edited here; the lint phase pins the
golangci-lint release that the policy names. cgo stays off: the Makefile exports
`CGO_ENABLED=0`, binaries are static, and the test phase runs on the alpine Go
image without `-race`, which is this repository's one departure from the
canonical `Dockerfile`. The runtime image carries the binary and the source it
was built from.
Version tags are created by the author alone, when the author decides. Tooling,
contributors, and agents working in this repository do not propose, schedule, or
create tags.
## Entrypoints
This repository adheres to the
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
standard: normalized scripts in `script/` are the entrypoints for the
development workflow, and the Makefile targets are thin shims that call them. We
provide:
- `script/bootstrap`: install everything the repository needs, idempotently:
make, git, go, and, because `yarn.lock` is present, node, yarn 1 and the
pinned prettier.
- `script/setup`: make a fresh clone ready for development by running
`script/bootstrap` and then `script/install-precommit`.
- `script/projectname`: print the project name, `x`, which `script/docker` and
`script/cibuild` use as the image tag.
- `script/test`: build the `test` phase of the `Dockerfile` with a new
`CACHEBUST` value, writing no image.
- `script/lint`: build the `lint` phase the same way.
- `script/fmt`: gofmt and prettier, writing files.
- `script/fmt-check`: the read-only twin of `script/fmt`.
- `script/check`: `script/test`, `script/lint`, then `script/fmt-check`. It
modifies nothing.
- `script/docker`: build the image tagged `x`, passing the version from
`git describe`.
- `script/cibuild`: what CI runs: `script/bootstrap`, `script/check`, then the
tagged image build.
- `script/precommit`: run by the pre-commit hook. It refuses a commit whose
`go.mod` or `go.sum` is not tidy, then runs `script/check`.
- `script/install-precommit`: install that hook; `make hooks` shims to it.
The Makefile adds four targets of its own: `make build`, `make install`,
`make tidy`, and `make clean`.
## Development
```sh
make setup # once: install the pinned prettier with yarn
make check # test + lint + fmt-check, exactly what CI gates on
make setup # once: bootstrap and install the pre-commit hook
make check # test, lint, fmt-check: what the hook and CI run
make fmt # gofmt and prettier, writes files
make hooks # install the pre-commit hook that runs make check
make docker # the full CI build
make build # build ./sutil on the host
make docker # build the image tagged x
```
## TODO
+773 -269
View File
File diff suppressed because it is too large Load Diff
+104
View File
@@ -0,0 +1,104 @@
#!/bin/sh
# script/bootstrap: install all dependencies needed to build and develop
# this repo. Idempotent: every install is guarded by a check so already
# installed tools are skipped. Base tools, go, node and yarn come from
# nix, apt, brew, or apk (detected in that order), with no version and
# no hash, because the distribution is trusted. It installs node, yarn
# and the node packages only when yarn.lock is in the tree. Assumes
# nothing is present.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
PKGMGR=""
SUDO=""
APT_UPDATED=""
detect_pkgmgr() {
[ -n "$PKGMGR" ] && return 0
if command -v nix-env >/dev/null 2>&1; then
PKGMGR="nix"
elif command -v apt-get >/dev/null 2>&1; then
PKGMGR="apt"
elif command -v brew >/dev/null 2>&1; then
PKGMGR="brew"
elif command -v apk >/dev/null 2>&1; then
PKGMGR="apk"
else
echo "bootstrap: no supported package manager (nix, apt, brew, apk)" >&2
exit 1
fi
if [ "$PKGMGR" = "apt" ]; then
export DEBIAN_FRONTEND=noninteractive
if [ "$(id -u)" != "0" ]; then
SUDO="sudo"
fi
fi
}
# pkg_install <nix-attr> <apt-pkg> <brew-formula> <apk-pkg>
pkg_install() {
detect_pkgmgr
case "$PKGMGR" in
nix) nix-env -iA "nixpkgs.$1" ;;
apt)
# Package lists may be empty (fresh images); refresh once per run.
if [ -z "$APT_UPDATED" ]; then
$SUDO env DEBIAN_FRONTEND=noninteractive apt-get update
APT_UPDATED=1
fi
$SUDO env DEBIAN_FRONTEND=noninteractive apt-get install -y "$2"
;;
brew) brew install "$3" ;;
apk) apk add --no-cache "$4" ;;
esac
}
missing() {
! command -v "$1" >/dev/null 2>&1
}
# Debian and Ubuntu name the yarn command yarnpkg.
run_yarn() {
if command -v yarn >/dev/null 2>&1; then
yarn "$@"
elif command -v yarnpkg >/dev/null 2>&1; then
yarnpkg "$@"
else
echo "no yarn; run script/bootstrap first" >&2
exit 1
fi
}
main() {
cd "$ROOT"
if missing make; then pkg_install gnumake make make make; fi
if missing git; then pkg_install git git git git; fi
# Go on the host is for script/fmt and script/fmt-check (gofmt) and
# for make build; the gates compile inside the Dockerfile's phases.
if missing go; then pkg_install go golang-go go go; fi
# A Dockerfile stage that needs no yarn copies no yarn.lock.
if [ -f yarn.lock ]; then
if missing node; then pkg_install nodejs nodejs node nodejs; fi
if missing yarn && missing yarnpkg; then
pkg_install yarn yarnpkg yarn yarn
fi
# Debian 13 and Ubuntu 26.04 package yarn 4, which refuses a yarn 1
# yarn.lock, or rewrites it in its own format.
yarn_version="$(run_yarn --version)"
if [ "${yarn_version%%.*}" != "1" ]; then
echo "bootstrap: yarn $yarn_version refuses this yarn 1 yarn.lock;" \
"use a distribution that packages yarn 1" >&2
exit 1
fi
run_yarn install --frozen-lockfile
fi
echo "bootstrap complete"
}
main "$@"
Executable
+16
View File
@@ -0,0 +1,16 @@
#!/bin/sh
# script/check: run all checks (test, lint, fmt-check). Our own
# extension to scripts-to-rule-them-all. test and lint are Docker
# phases; fmt-check is native, because a formatter writes the working
# tree. Must not modify any files.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
main() {
"$SCRIPT_DIR/test"
"$SCRIPT_DIR/lint"
"$SCRIPT_DIR/fmt-check"
}
main "$@"
Executable
+33
View File
@@ -0,0 +1,33 @@
#!/bin/sh
# script/cibuild: run the CI build. It bootstraps first: a CI runner
# checks out and runs this and nothing else, and script/fmt-check runs
# the formatter on the host, which a pristine checkout cannot do.
# A new CACHEBUST value reruns every RUN step after the ARG CACHEBUST
# line, since a cached gate phase is a check that did not run; unlike
# --no-cache, it keeps the layers above that line and the cache mounts.
# This relies on every stage of the Dockerfile declaring ARG CACHEBUST
# before every step that lints, tests or compiles; a stage without that
# line is served from cache on an unchanged tree, with no warning.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() {
cd "$ROOT"
"$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 --build-arg CACHEBUST="$(date +%s)-$$" \
--build-arg VERSION="$version" \
-t "$tag" .
}
main "$@"
Executable
+30
View File
@@ -0,0 +1,30 @@
#!/bin/sh
# script/docker: build the Docker image tagged with the project name.
# Identical in all repos; the tag comes from script/projectname.
# A new CACHEBUST value reruns every RUN step after the ARG CACHEBUST
# line, since a cached gate phase is a check that did not run; unlike
# --no-cache, it keeps the layers above that line and the cache mounts.
# This relies on every stage of the Dockerfile declaring ARG CACHEBUST
# before every step that lints, tests or compiles; a stage without that
# line is served from cache on an unchanged tree, with no warning.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() {
cd "$ROOT"
# 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 --build-arg CACHEBUST="$(date +%s)-$$" \
--build-arg VERSION="$version" \
-t "$tag" .
}
main "$@"
Executable
+29
View File
@@ -0,0 +1,29 @@
#!/bin/sh
# script/fmt: format all files (writes): gofmt for Go, prettier for
# markdown.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
GO_DIRS="cmd internal pkg"
# Debian and Ubuntu name the yarn command yarnpkg.
run_yarn() {
if command -v yarn >/dev/null 2>&1; then
yarn "$@"
elif command -v yarnpkg >/dev/null 2>&1; then
yarnpkg "$@"
else
echo "no yarn; run script/bootstrap first" >&2
exit 1
fi
}
main() {
cd "$ROOT"
# shellcheck disable=SC2086 # GO_DIRS is a word list on purpose
gofmt -w $GO_DIRS
run_yarn run prettier --write '**/*.md' --tab-width 4 --prose-wrap always
}
main "$@"
+34
View File
@@ -0,0 +1,34 @@
#!/bin/sh
# script/fmt-check: check formatting (read-only): gofmt for Go, prettier
# for markdown.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
GO_DIRS="cmd internal pkg"
# Debian and Ubuntu name the yarn command yarnpkg.
run_yarn() {
if command -v yarn >/dev/null 2>&1; then
yarn "$@"
elif command -v yarnpkg >/dev/null 2>&1; then
yarnpkg "$@"
else
echo "no yarn; run script/bootstrap first" >&2
exit 1
fi
}
main() {
cd "$ROOT"
# shellcheck disable=SC2086 # GO_DIRS is a word list on purpose
unformatted="$(gofmt -l $GO_DIRS)"
if [ -n "$unformatted" ]; then
echo "gofmt: the following files need formatting:" >&2
echo "$unformatted" >&2
exit 1
fi
run_yarn run prettier --check '**/*.md' --tab-width 4 --prose-wrap always
}
main "$@"
+16
View File
@@ -0,0 +1,16 @@
#!/bin/sh
# script/install-precommit: install the git pre-commit hook that runs
# script/precommit. Our own extension to scripts-to-rule-them-all.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
hook=".git/hooks/pre-commit"
printf '#!/bin/sh\nset -e\nscript/precommit\n' > .git/hooks/pre-commit
chmod +x .git/hooks/pre-commit
echo "pre-commit hook installed: runs script/precommit"
}
main "$@"
Executable
+28
View File
@@ -0,0 +1,28 @@
#!/bin/sh
# script/lint: run the linter. Linting is a phase of the Dockerfile and
# this builds that phase alone; the linter is never installed or run on
# a developer host, where a shared result cache and a host-global lock
# make its answer untrustworthy.
#
# The phase is not the last stage in the file, so it is built only when
# --target names it. A new CACHEBUST value reruns every RUN step after
# the ARG CACHEBUST line, since a cached lint layer is a lint that did
# not run; unlike --no-cache, it keeps the layers above that line and
# the cache mounts. This relies on every stage of the Dockerfile
# declaring ARG CACHEBUST before every step that lints, tests or
# compiles; a stage without that line is served from cache on an
# unchanged tree, with no warning.
# --output type=cacheonly writes no image, since nothing uses one.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() {
cd "$ROOT"
docker build --build-arg CACHEBUST="$(date +%s)-$$" \
--target lint \
--output type=cacheonly .
}
main "$@"
+22
View File
@@ -0,0 +1,22 @@
#!/bin/sh
# script/precommit: run by the git pre-commit hook; fails the commit if
# checks fail. Our own extension to scripts-to-rule-them-all. The Go
# extra: go.mod and go.sum must already be tidy, so a commit never
# carries a module file that go mod tidy would rewrite.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() {
cd "$ROOT"
go mod tidy
if ! git diff --quiet --exit-code -- go.mod go.sum; then
echo "precommit: go mod tidy changed go.mod or go.sum;" \
"stage them and retry" >&2
exit 1
fi
"$SCRIPT_DIR/check"
}
main "$@"
+12
View File
@@ -0,0 +1,12 @@
#!/bin/sh
# script/projectname: output the name of this project. Our own
# extension to scripts-to-rule-them-all. Other scripts that need the
# name (e.g. script/docker) call this, so they can stay identical
# across all repos.
set -eu
main() {
echo "x"
}
main "$@"
Executable
+13
View File
@@ -0,0 +1,13 @@
#!/bin/sh
# script/setup: set up the repo for development after a fresh clone:
# installs dependencies and the git pre-commit hook.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
main() {
"$SCRIPT_DIR/bootstrap"
"$SCRIPT_DIR/install-precommit"
}
main "$@"
Executable
+24
View File
@@ -0,0 +1,24 @@
#!/bin/sh
# script/test: run the test suite. Testing is a phase of the Dockerfile
# and this builds that phase alone, on the same terms as script/lint:
# --target because a phase that is not the last stage is built only when
# named, and a new CACHEBUST value because a cached test layer is a test
# that did not run; unlike --no-cache, it keeps the layers above the ARG
# CACHEBUST line and the cache mounts. This relies on every stage of the
# Dockerfile declaring ARG CACHEBUST before every step that lints, tests
# or compiles; a stage without that line is served from cache on an
# unchanged tree, with no warning. --output type=cacheonly writes no
# image, since nothing uses one.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() {
cd "$ROOT"
docker build --build-arg CACHEBUST="$(date +%s)-$$" \
--target test \
--output type=cacheonly .
}
main "$@"