Compare commits
67
Commits
a1ffb1591b
...
next
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b3508a4361 | ||
|
|
8fe0709414 | ||
|
|
01954b6946 | ||
|
|
6aac45857a | ||
|
|
f5c4bb6e2c | ||
|
|
cc440118c8 | ||
|
|
b09fff488b | ||
|
|
dd4027b907 | ||
|
|
5e5e7ea951 | ||
|
|
13125ac6f5 | ||
|
|
61a9afbb4f | ||
|
|
f3ad01a78c | ||
|
|
c32b10e77f | ||
|
|
c43c1f4bca | ||
|
|
567944f8d8 | ||
|
|
fa3202f214 | ||
|
|
562b40bfe5 | ||
|
|
5805909fb9 | ||
|
|
3c1b435990 | ||
|
|
5de98c404e | ||
|
|
dcc0ba0b66 | ||
|
|
343628fb3a | ||
|
|
7ea5cdcdcd | ||
|
|
507a57e813 | ||
|
|
2ae9391b26 | ||
|
|
ed5ed236b1 | ||
|
|
6c489067ce | ||
|
|
c4d5546e86 | ||
|
|
7b55c444ae | ||
|
|
c3a504f647 | ||
|
|
85bea7681e | ||
|
|
58f75147be | ||
|
|
58eafaf4c2 | ||
|
|
fbec5a523b | ||
|
|
f77d785bed | ||
|
|
3f8201d532 | ||
|
|
a8686891ba | ||
|
|
0f8efafe68 | ||
|
|
3d8d1c0600 | ||
|
|
cc5d877779 | ||
|
|
4b64c213f8 | ||
|
|
777822e50e | ||
|
|
1c84344978 | ||
|
|
41005ecbe5 | ||
|
|
eb6b11ee23 | ||
|
|
ee4f9039f2 | ||
|
|
18173fabc6 | ||
|
|
68a00dc545 | ||
|
|
533e77ad34 | ||
|
|
492fb85500 | ||
|
|
5c02cf8bde | ||
|
|
3ce000178f | ||
|
|
771551baed | ||
|
|
720d6ee57c | ||
|
|
5e15d77d8e | ||
|
|
2f4f5c9cab | ||
|
|
7eae7dcc6c | ||
|
|
6401aa482f | ||
|
|
e45ffacd80 | ||
|
|
c8ad5762ab | ||
|
|
e0e607713e | ||
|
|
3fcc1750ff | ||
|
|
45b379011d | ||
|
|
58d564b641 | ||
|
|
a1052b758f | ||
|
|
a2dd953601 | ||
|
|
f921dee839 |
+72
-3
@@ -1,3 +1,72 @@
|
|||||||
.git
|
# .dockerignore does NOT use .gitignore semantics. Docker matches with
|
||||||
node_modules
|
# moby/patternmatcher: filepath.Match plus `**`, so `*` does not cross
|
||||||
.DS_Store
|
# `/` 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-*
|
||||||
|
|||||||
@@ -10,3 +10,9 @@ insert_final_newline = true
|
|||||||
|
|
||||||
[Makefile]
|
[Makefile]
|
||||||
indent_style = tab
|
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.
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# Every PR adds an entry at the top of TODO.md's Completed Steps; union keeps
|
||||||
|
# both sides instead of conflicting. Git never reports a conflict here: read
|
||||||
|
# the merged entries after every merge or rebase.
|
||||||
|
TODO.md merge=union
|
||||||
@@ -1,9 +1,18 @@
|
|||||||
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
|
||||||
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
|
||||||
- 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
|
||||||
|
|||||||
+35
-5
@@ -11,11 +11,41 @@ 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
|
# 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/`.
|
||||||
|
|||||||
+74
-5
@@ -1,21 +1,33 @@
|
|||||||
version: "2"
|
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.
|
||||||
|
|
||||||
run:
|
run:
|
||||||
timeout: 5m
|
timeout: 5m
|
||||||
modules-download-mode: readonly
|
modules-download-mode: readonly
|
||||||
|
|
||||||
linters:
|
linters:
|
||||||
default: all
|
default: all
|
||||||
|
enable:
|
||||||
|
# Successor to the deprecated gomodguard. Named explicitly, rather than
|
||||||
|
# left to `default: all`, because it carries the module policy below.
|
||||||
|
- gomodguard_v2
|
||||||
disable:
|
disable:
|
||||||
# Genuinely incompatible with project patterns
|
# Genuinely incompatible with project patterns
|
||||||
- exhaustruct # Requires all struct fields
|
- exhaustruct # Requires all struct fields
|
||||||
- depguard # Dependency allow/block lists
|
- exhaustruct_v5 # Requires all struct fields (successor to exhaustruct)
|
||||||
- godot # Requires comments to end with periods
|
- godot # Requires comments to end with periods
|
||||||
- wsl # Deprecated, replaced by wsl_v5
|
|
||||||
- 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
|
||||||
|
# Deprecated: the warning is attached to the old name, so it is
|
||||||
linters-settings:
|
# silenced by disabling that name, not by enabling the successor.
|
||||||
|
- wsl # Deprecated, replaced by wsl_v5
|
||||||
|
- gomodguard # Deprecated, replaced by gomodguard_v2
|
||||||
|
# Misses findings at random in v2.14.0; back once a pinned release fixes it
|
||||||
|
- canonicalheader
|
||||||
|
settings:
|
||||||
lll:
|
lll:
|
||||||
line-length: 88
|
line-length: 88
|
||||||
funlen:
|
funlen:
|
||||||
@@ -25,8 +37,65 @@ linters-settings:
|
|||||||
max-complexity: 15
|
max-complexity: 15
|
||||||
dupl:
|
dupl:
|
||||||
threshold: 100
|
threshold: 100
|
||||||
|
depguard:
|
||||||
|
# Test-support code must not be compiled into the shipped binary. A
|
||||||
|
# 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.
|
||||||
|
#
|
||||||
|
# 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
|
||||||
|
# import path against a list of prefixes, so it cannot be told "any path
|
||||||
|
# whose last segment ends in test"; a repository's own test-support
|
||||||
|
# packages have to be named here one at a time, by full import path,
|
||||||
|
# under a module path that differs from repository to repository. Add
|
||||||
|
# them; change nothing else.
|
||||||
|
rules:
|
||||||
|
test-support:
|
||||||
|
list-mode: lax
|
||||||
|
files:
|
||||||
|
- "$all"
|
||||||
|
- "!$test"
|
||||||
|
- "!**/*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.
|
||||||
|
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.
|
||||||
|
- module: github.com/go-redis/redis
|
||||||
|
recommendations:
|
||||||
|
- github.com/redis/go-redis/v9
|
||||||
|
reason: "Pre-fork module; use the maintained go-redis v9."
|
||||||
|
- module: github.com/go-redis/redis/v7
|
||||||
|
recommendations:
|
||||||
|
- github.com/redis/go-redis/v9
|
||||||
|
reason: "Pre-fork module; use the maintained go-redis v9."
|
||||||
|
- module: github.com/go-redis/redis/v8
|
||||||
|
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
|
||||||
|
reason: "No unified diff output; use go-udiff."
|
||||||
|
- module: github.com/hexops/gotextdiff
|
||||||
|
recommendations:
|
||||||
|
- github.com/aymanbagabas/go-udiff
|
||||||
|
reason: "Unmaintained fork; use go-udiff."
|
||||||
|
|
||||||
issues:
|
issues:
|
||||||
exclude-use-default: false
|
|
||||||
max-issues-per-linter: 0
|
max-issues-per-linter: 0
|
||||||
max-same-issues: 0
|
max-same-issues: 0
|
||||||
|
|||||||
+52
-4
@@ -1,11 +1,59 @@
|
|||||||
|
# Lint phase. The linter is invoked directly rather than through `make
|
||||||
|
# lint` or `script/lint`, which are themselves a docker build and would
|
||||||
|
# recurse into a daemon that does not exist in a build step.
|
||||||
|
#
|
||||||
|
# node 22-alpine, 2026-02-22
|
||||||
|
FROM node@sha256:e4bf2a82ad0a4037d28035ae71529873c069b13eb0455466ae0bc13363826e34 AS lint
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY script/ script/
|
||||||
|
COPY package.json yarn.lock ./
|
||||||
|
RUN script/bootstrap
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
RUN yarn run prettier --check '**/*.md' --tab-width 4 --prose-wrap always
|
||||||
|
|
||||||
|
# Test phase, same shape and for the same reason.
|
||||||
|
#
|
||||||
|
# node 22-alpine, 2026-02-22
|
||||||
|
FROM node@sha256:e4bf2a82ad0a4037d28035ae71529873c069b13eb0455466ae0bc13363826e34 AS test
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY script/ script/
|
||||||
|
COPY package.json yarn.lock ./
|
||||||
|
RUN script/bootstrap
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
RUN echo "No tests defined."
|
||||||
|
|
||||||
|
# Development environment, and the last stage: a plain `docker build .`
|
||||||
|
# names no target and so builds this one. Nothing is wanted from the two
|
||||||
|
# phases above; the copies are what make BuildKit build them first, so
|
||||||
|
# this image cannot be produced unless lint and test passed. A stage
|
||||||
|
# appended after this one would drop all three out of a plain build.
|
||||||
|
#
|
||||||
# node 22-alpine, 2026-02-22
|
# node 22-alpine, 2026-02-22
|
||||||
FROM node@sha256:e4bf2a82ad0a4037d28035ae71529873c069b13eb0455466ae0bc13363826e34
|
FROM node@sha256:e4bf2a82ad0a4037d28035ae71529873c069b13eb0455466ae0bc13363826e34
|
||||||
|
|
||||||
RUN apk add --no-cache make
|
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY --from=lint /app/package.json /dev/null
|
||||||
|
COPY --from=test /app/package.json /dev/null
|
||||||
|
|
||||||
|
# script/bootstrap installs all prerequisites. Manifests are copied
|
||||||
|
# first so that layer stays cached until dependencies change.
|
||||||
|
COPY script/ script/
|
||||||
COPY package.json yarn.lock ./
|
COPY package.json yarn.lock ./
|
||||||
RUN yarn install --frozen-lockfile
|
RUN script/bootstrap
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
RUN make check
|
# Nothing here is compiled and a LABEL cannot run git, so the version is
|
||||||
|
# the VERSION build argument that script/docker and script/cibuild pass;
|
||||||
|
# a plain `docker build .` leaves it empty.
|
||||||
|
ARG VERSION
|
||||||
|
LABEL org.opencontainers.image.version="${VERSION}"
|
||||||
|
|||||||
@@ -1,31 +1,32 @@
|
|||||||
.PHONY: test lint fmt fmt-check check docker hooks
|
.PHONY: bootstrap setup test lint fmt fmt-check check docker hooks
|
||||||
|
|
||||||
# flags are repeated here (also in .prettierrc) so this Makefile works
|
# Makefile targets are thin shims; the implementations live in script/
|
||||||
# standalone when copied as a template
|
# per the scripts-to-rule-them-all pattern (see the Entrypoints section
|
||||||
PRETTIER := yarn run prettier
|
# of README.md).
|
||||||
|
|
||||||
|
bootstrap:
|
||||||
|
@script/bootstrap
|
||||||
|
|
||||||
|
setup:
|
||||||
|
@script/setup
|
||||||
|
|
||||||
test:
|
test:
|
||||||
@echo "No tests defined."
|
@script/test
|
||||||
|
|
||||||
lint:
|
lint:
|
||||||
@echo "Linting markdown files..."
|
@script/lint
|
||||||
@$(PRETTIER) --check '**/*.md' --tab-width 4 --prose-wrap always
|
|
||||||
|
|
||||||
fmt:
|
fmt:
|
||||||
@$(PRETTIER) --write '**/*.md' --tab-width 4 --prose-wrap always
|
@script/fmt
|
||||||
|
|
||||||
fmt-check:
|
fmt-check:
|
||||||
@$(PRETTIER) --check '**/*.md' --tab-width 4 --prose-wrap always
|
@script/fmt-check
|
||||||
|
|
||||||
check: test lint fmt-check
|
check:
|
||||||
|
@script/check
|
||||||
|
|
||||||
docker:
|
docker:
|
||||||
docker build -t prompts .
|
@script/docker
|
||||||
|
|
||||||
hooks:
|
hooks:
|
||||||
@printf '#!/bin/sh\nset -e\n' > .git/hooks/pre-commit
|
@script/install-precommit
|
||||||
@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
|
|
||||||
|
|||||||
@@ -102,6 +102,44 @@ cd prompts
|
|||||||
Prompts are stored as Markdown files in `prompts/`. Copy or reference them as
|
Prompts are stored as Markdown files in `prompts/`. Copy or reference them as
|
||||||
needed in your projects.
|
needed in your projects.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
The scripts are POSIX sh (not bash) so they run in minimal containers such as
|
||||||
|
alpine. We provide:
|
||||||
|
|
||||||
|
- `script/bootstrap` — install all dependencies (yarn install)
|
||||||
|
- `script/setup` — set up the repo for development after a fresh clone: runs
|
||||||
|
`script/bootstrap`, then `script/install-precommit`
|
||||||
|
- `script/projectname` — output the project name (our own extension); used by
|
||||||
|
`script/docker` for the image tag
|
||||||
|
- `script/test` — `docker build --no-cache --target test -t prompts-test .`,
|
||||||
|
building the `test` phase of the `Dockerfile` (no tests defined here)
|
||||||
|
- `script/lint` — `docker build --no-cache --target lint -t prompts-lint .`,
|
||||||
|
building the `lint` phase, which runs prettier over the markdown files
|
||||||
|
- `script/fmt` — format all markdown files with prettier (writes; native, not in
|
||||||
|
a container)
|
||||||
|
- `script/fmt-check` — check formatting (read-only; native)
|
||||||
|
- `script/check` — run all checks: `test`, `lint`, `fmt-check` (our own
|
||||||
|
extension); builds no image of its own
|
||||||
|
- `script/docker` —
|
||||||
|
`docker build --no-cache --build-arg VERSION="$version" -t prompts .`, the tag
|
||||||
|
coming from `script/projectname` (byte-identical across repos)
|
||||||
|
- `script/cibuild` — cd to the repo root, run `script/bootstrap`, run
|
||||||
|
`script/check`, compute `version` from `git describe`, then
|
||||||
|
`docker build --no-cache --build-arg VERSION="$version" -t prompts .` (what CI
|
||||||
|
runs; it bootstraps because CI checks out and runs this alone while
|
||||||
|
`script/fmt-check` is native)
|
||||||
|
- `script/precommit` — run by the git pre-commit hook (our own extension); calls
|
||||||
|
`script/check`
|
||||||
|
- `script/install-precommit` — installs the git pre-commit hook (our own
|
||||||
|
extension); `make hooks` shims to it
|
||||||
|
|
||||||
|
`make hooks` installs the pre-commit hook that runs `script/precommit`.
|
||||||
|
|
||||||
## Rationale
|
## Rationale
|
||||||
|
|
||||||
LLM prompts, especially development policies, benefit from version control and a
|
LLM prompts, especially development policies, benefit from version control and a
|
||||||
|
|||||||
@@ -0,0 +1,252 @@
|
|||||||
|
# Workflow
|
||||||
|
|
||||||
|
- branch (from `main`)
|
||||||
|
- do the work in Next Step
|
||||||
|
- move Next Step to the top of Completed Steps
|
||||||
|
- move the top item of Future Steps into Next Step
|
||||||
|
- commit (`TODO.md` changes in the same commit as the work)
|
||||||
|
- merge to `main` if the branch is not protected, otherwise open a PR
|
||||||
|
- push
|
||||||
|
|
||||||
|
# Status
|
||||||
|
|
||||||
|
pre-1.0
|
||||||
|
|
||||||
|
# Next Step
|
||||||
|
|
||||||
|
Finish the two draft prompt documents in the working tree and commit them:
|
||||||
|
prompts/FIXUP_CLEAN.md (currently a near-empty stub) and prompts/FIXUP_REPORT.md
|
||||||
|
(a rough draft). Write the missing content, run `make fmt` so they pass
|
||||||
|
fmt-check, and commit.
|
||||||
|
|
||||||
|
# Completed Steps
|
||||||
|
|
||||||
|
- 2026-10-06: The canonical `.gitea/workflows/check.yml` now sets
|
||||||
|
`fetch-depth: 0` on its checkout step (issue 110), so CI fetches the history
|
||||||
|
and tags that `git describe --tags --always` needs, and a tagged repository
|
||||||
|
stamps the same version in CI as in a local build. `REPO_POLICIES.md` and both
|
||||||
|
checklists name `fetch-depth: 0` among what the workflow does, next to
|
||||||
|
`persist-credentials: false` and the `concurrency` block, instead of asking
|
||||||
|
each tagged repository to add it. Not yet tried on the shared runner, which is
|
||||||
|
out of disk space. Repositories pick this up on their next re-vendor.
|
||||||
|
- 2026-10-06: The canonical `script/bootstrap` now runs `apt-get update` once,
|
||||||
|
before the first `apt-get install` of a run (issue 115). The Gitea runner
|
||||||
|
image starts with empty package lists, so installing anything it lacks, such
|
||||||
|
as Go, failed with `Unable to locate package`. A repository's own section no
|
||||||
|
longer needs a refresh of its own; `sneak/bsfirehose` and `sneak/dnswatcher`
|
||||||
|
drop theirs at their next re-vendor.
|
||||||
|
- 2026-10-06: `REPO_POLICIES.md` and both checklists now say that a checkout
|
||||||
|
whose `.git` is a file, a linked worktree or a repository checked out as a
|
||||||
|
submodule, is the exception to a plain `docker build .` succeeding (issue
|
||||||
|
111): that file points to a git directory outside the build context, so the
|
||||||
|
build cannot read the version and the version check in the canonical
|
||||||
|
`Dockerfile` stops it. Such a build is given its version with
|
||||||
|
`--build-arg VERSION=...`, as `script/docker` and `script/cibuild` already do.
|
||||||
|
The check itself is unchanged.
|
||||||
|
- 2026-10-06: The canonical `.gitea/workflows/check.yml` now has a `concurrency`
|
||||||
|
block, so a new push cancels the older run on the same branch and no other,
|
||||||
|
and its checkout step sets `persist-credentials: false`, so the job's token is
|
||||||
|
not left in `.git/config` (issue 107). `REPO_POLICIES.md` and both checklists
|
||||||
|
describe the workflow as it now is. Not yet tried on the shared runner, which
|
||||||
|
is out of disk space. Repositories pick this up on their next re-vendor.
|
||||||
|
- 2026-10-06: The canonical `.golangci.yml` now disables `canonicalheader`
|
||||||
|
(issue 105). In golangci-lint v2.14.0 it misses findings at random in a
|
||||||
|
package that also calls `ResponseWriter.Header()`, so the same tree can fail
|
||||||
|
lint on one run and pass on the next. It comes back once a pinned
|
||||||
|
golangci-lint release fixes it.
|
||||||
|
- 2026-10-06: The canonical `.gitignore` and `.editorconfig` now each end with a
|
||||||
|
comment saying the repository's own entries go below it and a re-vendor keeps
|
||||||
|
them (issue 103, which took in issue 104), as `.dockerignore`'s header already
|
||||||
|
does. `.editorconfig` gains a `[*.go]` section with tabs, since `gofmt`
|
||||||
|
decides Go indentation everywhere. `REPO_POLICIES.md` and both checklists say
|
||||||
|
that each of these two files is the canonical content followed by the
|
||||||
|
repository's own entries, which a re-vendor keeps, and the Go styleguide puts
|
||||||
|
`*.log`, `*.out`, `*.test` and binaries among those entries. Common outputs
|
||||||
|
stay out of the canonical `.gitignore`; each repository lists its own.
|
||||||
|
- 2026-10-05: `script/cibuild`, `script/docker`, `script/lint` and `script/test`
|
||||||
|
now assign the image tag from `script/projectname` on its own line before the
|
||||||
|
`docker build` (issue 101), so `set -e` stops the script where
|
||||||
|
`script/projectname` fails instead of running `docker build` with a broken
|
||||||
|
tag. The comment above it in each script says why, and the snippets in
|
||||||
|
`REPO_POLICIES.md` show the same form. Repositories pick this up on their next
|
||||||
|
re-vendor.
|
||||||
|
- 2026-10-04: Went through the fleet findings recorded on 2026-08-09 (issue 62)
|
||||||
|
and added the two rules `REPO_POLICIES.md` did not yet state: a new or changed
|
||||||
|
check is proven by planting a defect it must catch; and a change to a separate
|
||||||
|
workflow limited to `main` is first run from the feature branch, added to that
|
||||||
|
workflow's `branches` list and removed again before merging. The other
|
||||||
|
findings were already stated, replaced by `--no-cache`, about git worktrees,
|
||||||
|
or about how agents work together. The warning against
|
||||||
|
`golangci-lint config verify` is dropped because sneak ruled on
|
||||||
|
https://git.eeqj.de/sneak/prompts/issues/40 (2026-08-10) that there is no
|
||||||
|
config check step and the config is assumed valid; a vendored `.golangci.yml`
|
||||||
|
stays byte-identical to the canonical copy. The issue gives each reason.
|
||||||
|
- 2026-10-04: `REPO_POLICIES.md` now says which `Dockerfile` stages run
|
||||||
|
`script/bootstrap` (issue 90). The gate phases and the build stage start from
|
||||||
|
their pinned base images and install what those images lack either inline, as
|
||||||
|
the canonical Go `Dockerfile` does for `git`, or by running
|
||||||
|
`script/bootstrap`, as this repo's own `Dockerfile` does for its yarn
|
||||||
|
packages. The development environment stage, the final stage of a non-server
|
||||||
|
repo, runs `script/bootstrap`. The new repo checklist says the same.
|
||||||
|
- 2026-10-04: Added a root `.gitattributes` that merges `TODO.md` with git's
|
||||||
|
union merge (issue 98), so two branches that each add an entry at the top of
|
||||||
|
Completed Steps merge without a conflict. Git now never reports a conflict in
|
||||||
|
`TODO.md`: a real conflict elsewhere keeps both versions of the line, and when
|
||||||
|
two new entries share an identical line, one is inserted into the middle of
|
||||||
|
the other, which a rebase can do to an entry already on `next`. Read the
|
||||||
|
merged entries after every merge or rebase. This applies to this repository
|
||||||
|
only; no canonical file changed.
|
||||||
|
- 2026-10-04: The canonical `.dockerignore` now also keeps out the git `config`
|
||||||
|
of a submodule that keeps its own `.git` directory, which still reached the
|
||||||
|
image (issue 88): both git patterns now carry the `**/` prefix. A submodule
|
||||||
|
whose name has a `config` segment (`config`, `deploy/config`, `config/lib`)
|
||||||
|
still loses its whole git directory, so Go's version stamping fails the build;
|
||||||
|
the file records this as a `KNOWN GAP:` with the remedy,
|
||||||
|
`git submodule add --name`. Closing it would take a wildcard re-include, which
|
||||||
|
makes BuildKit walk every excluded directory, such as `node_modules`, on every
|
||||||
|
build. `REPO_POLICIES.md` and both checklists say so in the same words.
|
||||||
|
- 2026-10-04: The note under the canonical Go `Dockerfile` example in
|
||||||
|
`REPO_POLICIES.md` now installs lint-phase system libraries with `apt-get`
|
||||||
|
under their Debian package names (issue 83). The `golangci/golangci-lint`
|
||||||
|
image is Debian-based and has no `apk`, so the old `apk add` instruction
|
||||||
|
failed as written. Nothing is pinned or unpinned; that is still open on
|
||||||
|
issue 72.
|
||||||
|
- 2026-10-04: Fixed the server lifecycle example in
|
||||||
|
`prompts/GO_HTTP_SERVER_CONVENTIONS.md` (issue 86). Only fx handles SIGINT and
|
||||||
|
SIGTERM, and `Run()` in `main` exits with the shutdown's exit code. A listen
|
||||||
|
error asks fx to shut down with exit code 1 through `fx.Shutdowner`; a Sentry
|
||||||
|
start failure is returned from the server's start hook instead of calling
|
||||||
|
`os.Exit` from a goroutine, so the stop hooks of what had started still run.
|
||||||
|
The server's stop hook shuts the HTTP server down within 5 seconds and fails
|
||||||
|
when requests are still running. A new paragraph says who owns signals and the
|
||||||
|
exit code.
|
||||||
|
- 2026-10-04: `REPO_POLICIES.md` now says how a Go tool a repo needs on the host
|
||||||
|
is pinned (issue 37): installed with `go install` pinned to a commit hash,
|
||||||
|
never tracked as a `go.mod` tool dependency or through a `tools.go` file.
|
||||||
|
golangci-lint is unaffected, since no repo installs it on the host.
|
||||||
|
- 2026-10-04: The canonical `.gitignore` and `.dockerignore` now also keep out
|
||||||
|
`id_ecdsa_sk` and `id_ed25519_sk`, the private key files `ssh-keygen` writes
|
||||||
|
for keys backed by a hardware security key (issue 81). Their `.pub` halves
|
||||||
|
stay trackable.
|
||||||
|
- 2026-10-04: `package.json` now has `"license": "MIT"`, matching `LICENSE`, so
|
||||||
|
yarn no longer prints "No license field" when `script/bootstrap` runs it
|
||||||
|
inside the Docker phases (issue 76). That was the only yarn warning there.
|
||||||
|
- 2026-10-04: `REPO_POLICIES.md` now states that guidance for coding agents
|
||||||
|
lives in one `AGENTS.md` at the repository root, never under a file or
|
||||||
|
directory named after one agent tool and never in separate memory files (issue
|
||||||
|
31). This retires the rule, still present in older vendored copies, that kept
|
||||||
|
agent memory as committed files under `.claude/memory/`. `AGENTS.md` joins the
|
||||||
|
list of files allowed in the root, and both checklists say so.
|
||||||
|
- 2026-10-04: Rewrote the note under the canonical Go `make test` example in
|
||||||
|
`REPO_POLICIES.md` (issue 77), which still named the cache-busting build
|
||||||
|
argument that `--no-cache` replaced. It now says where Go's test result cache
|
||||||
|
can replay a pass: on a developer's machine, where the Makefile target runs,
|
||||||
|
and not in the `test` phase of the `Dockerfile`, whose base image and earlier
|
||||||
|
steps hold no result for the repo's tests.
|
||||||
|
- 2026-10-04: The Makefile examples in the Go styleguide and the HTTP server
|
||||||
|
conventions now fall back to `dev` when `git describe` prints nothing (outside
|
||||||
|
a git checkout, or where git is missing or refuses the checkout), instead of
|
||||||
|
stamping an empty version (issue 74). The canonical `Dockerfile` already fails
|
||||||
|
on a `dev` version when `.git` is in the build context.
|
||||||
|
- 2026-10-04: The canonical `.dockerignore` now also keeps out each submodule's
|
||||||
|
`config` (issue 75). A submodule's git directory lives under `.git/modules/`,
|
||||||
|
nested again for its own submodules, and its `config` can hold a credential
|
||||||
|
just like `.git/config`. The pattern `.git/modules/**/config` covers every
|
||||||
|
depth and leaves the top-level `.git` that `git describe` reads untouched.
|
||||||
|
`REPO_POLICIES.md` and both checklists say so in the same words.
|
||||||
|
- 2026-10-03: Fixed two defects in the canonical Go `Dockerfile` example (issue
|
||||||
|
73). The test phase now uses the Debian Go image, since `-race` needs cgo and
|
||||||
|
the alpine image has no C compiler, so the phase failed before running a test.
|
||||||
|
The stage that compiles runs `git config --system --add safe.directory /src`,
|
||||||
|
because a context sent as a tar stream keeps the sender's file owners and git
|
||||||
|
refuses that checkout, leaving the version empty. Both checklists state that
|
||||||
|
step in the same words.
|
||||||
|
- 2026-10-03: Moved the canonical golangci-lint to v2.14.0, built with go1.27,
|
||||||
|
because v2.12.2 refuses to lint a module whose `go` directive is 1.27 (issue
|
||||||
|
65). Releases from v2.13.0 deprecate `exhaustruct` in favour of
|
||||||
|
`exhaustruct_v5`, which `default: all` switches on, so the canonical
|
||||||
|
`.golangci.yml` now disables `exhaustruct_v5` beside `exhaustruct`. v2.12.2
|
||||||
|
rejects that file, so `REPO_POLICIES.md` and both repo checklists now say a
|
||||||
|
repo sets the lint phase digest and re-vendors `.golangci.yml` in one commit.
|
||||||
|
- 2026-10-03: Brought the canonical `.gitignore` level with `.dockerignore` on
|
||||||
|
secrets (issue 38): it now also ignores `prod.env`-style `*.env` files,
|
||||||
|
`.envrc`, `*.p12`, `*.pfx` and the extensionless SSH private keys, written to
|
||||||
|
`.gitignore`'s own rules (no `**/` prefix) and case-folded with character
|
||||||
|
ranges. `example.env` and `sample.env` stay trackable through negations.
|
||||||
|
- 2026-10-02: The image version now comes from git inside the build (issues 69
|
||||||
|
and 71), superseding the 2026-09-08 entry that excluded `.git`. The canonical
|
||||||
|
`.dockerignore` sends `.git` but keeps out `.git/config`, which can hold a
|
||||||
|
credential. The Dockerfile example in `REPO_POLICIES.md` installs `git`, takes
|
||||||
|
the `VERSION` build argument when one is given and otherwise
|
||||||
|
`git describe --tags --always`, and fails when `.git` exists but the version
|
||||||
|
is empty, `dev` or `unknown`; a plain `docker build .` with no build arguments
|
||||||
|
must succeed. This repo's `script/docker` and `script/cibuild` still pass
|
||||||
|
`--build-arg VERSION`, since its own `Dockerfile` compiles nothing.
|
||||||
|
- 2026-09-08: Moved linting and testing into Docker as phases of the main
|
||||||
|
`Dockerfile`, per the owner ruling on issue 40. `script/lint` and
|
||||||
|
`script/test` build one phase each by name with `--no-cache` — the same answer
|
||||||
|
issue 26 got, so no separate cache-busting mechanism survives — and the final
|
||||||
|
stage copies a harmless file from both, so the image cannot be built unless
|
||||||
|
they pass. This also closes issue 30: a container has its own result cache and
|
||||||
|
its own lock, so a lint verdict can no longer belong to another checkout. No
|
||||||
|
separate lint Dockerfile, and no `golangci-lint config verify` step.
|
||||||
|
`script/check` runs the gates and nothing else, and `script/cibuild`
|
||||||
|
bootstraps first, since it is all CI runs and `script/fmt-check` is native.
|
||||||
|
- 2026-09-08: Kept in-repo agent scratch out of the Docker build context and out
|
||||||
|
of version control: `.claude/` is one full checkout of the repo per in-flight
|
||||||
|
agent, and under `COPY . .` all of it was reaching the image. Also closed the
|
||||||
|
consequence of excluding `.git` — `git describe` yields an empty version
|
||||||
|
inside a build stage without failing, so `script/docker` and `script/cibuild`
|
||||||
|
now compute the version on the host and pass `--build-arg VERSION`.
|
||||||
|
- 2026-09-08: Closed the secret exposure in the canonical `.dockerignore`: a
|
||||||
|
local `.env`, `*.pem` or `*.key` was reaching the build context under
|
||||||
|
`COPY . .`, invisible to every git-based check. The patterns are now written
|
||||||
|
to `.dockerignore`'s own semantics — `**/`-prefixed so they hold at every
|
||||||
|
depth, case-folded with character ranges — and `REPO_POLICIES.md` requires
|
||||||
|
verifying by enumerating the image rather than by reading the file.
|
||||||
|
- 2026-09-08: Made a pinned tool in `script/bootstrap` actually reach the host.
|
||||||
|
`REPO_POLICIES.md` now requires comparing the installed version against the
|
||||||
|
pin rather than testing `PATH` presence, and re-resolving the binary through
|
||||||
|
`PATH` after installing, so a version bump cannot be a silent no-op and a
|
||||||
|
shadowed install cannot report success.
|
||||||
|
- 2026-09-08: Closed the false green in the canonical CI gate: `script/cibuild`
|
||||||
|
and `script/docker` now build with `--no-cache`, so the Dockerfile's check
|
||||||
|
layers cannot be served from cache on an unchanged tree, and the text claiming
|
||||||
|
a bare `docker build .` proves the checks ran is corrected in
|
||||||
|
`REPO_POLICIES.md`, both checklists and the Go styleguide.
|
||||||
|
- 2026-09-03: Added `-count=1` to both `go test` invocations in the canonical Go
|
||||||
|
`make test` example in `REPO_POLICIES.md`, so the target cannot report a
|
||||||
|
cached pass it did not earn, and documented that Go's test-result cache is a
|
||||||
|
second, independent cache stacked below the Docker layer cache.
|
||||||
|
- 2026-08-31: Migrated the canonical `.golangci.yml` from the deprecated
|
||||||
|
`gomodguard` to `gomodguard_v2`: the old linter is disabled by name (which is
|
||||||
|
what silences the deprecation warning), the successor is named explicitly in
|
||||||
|
`linters.enable`, and it carries a `blocked` module list drawn only from
|
||||||
|
decisions already recorded in the Go package defaults.
|
||||||
|
- 2026-08-07: Set the canonical `.golangci.yml` to the org-standard v2-schema
|
||||||
|
config already deployed byte-identical across the org's Go repos (settings
|
||||||
|
under `linters.settings` so thresholds like lll/funlen/cyclop/dupl actually
|
||||||
|
apply under golangci-lint v2). Recorded the canonical golangci-lint version
|
||||||
|
(v2.12.2, commit-pinned) in REPO_POLICIES.md.
|
||||||
|
- 2026-03-20: Strengthened constructor naming and Params struct rules in the Go
|
||||||
|
styleguide.
|
||||||
|
- 2026-03-18: Documented fail-fast Dockerfile lint stage and conditional -v test
|
||||||
|
rerun patterns in REPO_POLICIES.md.
|
||||||
|
- 2026-03-11: Added HTTP service hardening policy for 1.0 releases.
|
||||||
|
- 2026-03-10: Added policy: no build artifacts in repos.
|
||||||
|
- 2026-03-04: Added LLM prose tells reference and copyediting checklist, then
|
||||||
|
several self-applied revision passes.
|
||||||
|
- 2026-02-28: Expanded the pre-1.0 schema migration rule; added clawpub
|
||||||
|
reference.
|
||||||
|
- 2026-02-23: Added Go style rules (no type-only packages, Stringer for
|
||||||
|
string-based types); template repos section in README.
|
||||||
|
- 2026-02-22: Initial policy corpus: REPO_POLICIES.md, code styleguides
|
||||||
|
(general, Go, JS, Python), repo checklists, CI policy, hash pinning, Go HTTP
|
||||||
|
server conventions, repo scaffolding.
|
||||||
|
|
||||||
|
# Future Steps
|
||||||
|
|
||||||
|
- Finish, format, and commit FIXUP_CLEAN.md and FIXUP_REPORT.md (the Next Step).
|
||||||
|
- Commit this TODO.md at the repo root; it is the last missing policy file.
|
||||||
|
- Decide the fate of untracked resume.sh: commit it or delete it.
|
||||||
|
- Add more prompt templates for common development tasks (from README TODO).
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
{
|
{
|
||||||
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"prettier": "3.8.1"
|
"prettier": "3.8.1"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
title: Code Styleguide — Go
|
title: Code Styleguide — Go
|
||||||
last_modified: 2026-02-22
|
last_modified: 2026-10-06
|
||||||
---
|
---
|
||||||
|
|
||||||
1. Try to hard wrap long lines at 77 characters or less.
|
1. Try to hard wrap long lines at 77 characters or less.
|
||||||
@@ -24,7 +24,8 @@ last_modified: 2026-02-22
|
|||||||
1. Embed the git commit hash into the binary and include it in startup logs and
|
1. Embed the git commit hash into the binary and include it in startup logs and
|
||||||
in health check output. This is to make it easier to correlate running
|
in health check output. This is to make it easier to correlate running
|
||||||
instances with their code. Do not include build time or build user, as these
|
instances with their code. Do not include build time or build user, as these
|
||||||
will make the build nondeterministic.
|
will make the build nondeterministic. The architecture is not passed in at
|
||||||
|
build time; a program that reports it reads `runtime.GOARCH` at run time.
|
||||||
|
|
||||||
Example relevant Makefile sections:
|
Example relevant Makefile sections:
|
||||||
|
|
||||||
@@ -35,25 +36,27 @@ last_modified: 2026-02-22
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"runtime"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var Version string
|
||||||
Version string
|
|
||||||
Buildarch string
|
|
||||||
)
|
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
fmt.Printf("Version: %s\n", Version)
|
fmt.Printf("Version: %s\n", Version)
|
||||||
fmt.Printf("Buildarch: %s\n", Buildarch)
|
fmt.Printf("Arch: %s\n", runtime.GOARCH)
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
```make
|
```make
|
||||||
VERSION := $(shell git describe --always --dirty)
|
# ?= rather than := so that a `VERSION` build argument takes precedence:
|
||||||
BUILDARCH := $(shell uname -m)
|
# where a build stage invokes make, `ARG VERSION` puts it in the
|
||||||
|
# environment and `?=` defers to it. Otherwise `git describe` runs, in a
|
||||||
|
# build stage on the `.git` the build context carries. When it prints
|
||||||
|
# nothing (outside a git checkout, or where git is missing or refuses the
|
||||||
|
# checkout), the version falls back to `dev`.
|
||||||
|
VERSION ?= $(or $(shell git describe --tags --always 2>/dev/null),dev)
|
||||||
|
|
||||||
GOLDFLAGS += -X main.Version=$(VERSION)
|
GOLDFLAGS += -X main.Version=$(VERSION)
|
||||||
GOLDFLAGS += -X main.Buildarch=$(BUILDARCH)
|
|
||||||
|
|
||||||
# osx can't statically link apparently?!
|
# osx can't statically link apparently?!
|
||||||
ifeq ($(UNAME_S),Darwin)
|
ifeq ($(UNAME_S),Darwin)
|
||||||
@@ -98,12 +101,19 @@ last_modified: 2026-02-22
|
|||||||
|
|
||||||
1. For anything beyond a simple script or tool, or anything that is going to
|
1. For anything beyond a simple script or tool, or anything that is going to
|
||||||
run in any sort of "production" anywhere, make sure it passes
|
run in any sort of "production" anywhere, make sure it passes
|
||||||
`golangci-lint`.
|
`golangci-lint`. Run it with `make lint`, never by invoking the binary: the
|
||||||
|
linter runs as a phase of the `Dockerfile` and is not installed on the host
|
||||||
|
by any repo. Invoked directly on a shared host it reads a result cache keyed
|
||||||
|
on file content rather than location, and a host-global lock, so its answer
|
||||||
|
may belong to another checkout entirely.
|
||||||
|
|
||||||
1. Write a `Dockerfile` for every repo, even if it only runs the tests and
|
1. Write a `Dockerfile` for every repo, even if it only runs the tests and
|
||||||
linting. `docker build .` should always make sure that the code is in an
|
linting. It carries the lint and test phases, and the final stage depends on
|
||||||
able-to-be-compiled state, linted, and any tests run. The Docker build
|
both, so a build makes sure the code is in an able-to-be-compiled state,
|
||||||
should fail if linting doesn't pass.
|
linted, and its tests run. Go through `script/cibuild` or `script/docker`
|
||||||
|
rather than a bare `docker build .`: they pass `--no-cache`, without which
|
||||||
|
an unchanged tree serves the gate layers from cache and the build reports a
|
||||||
|
green it never ran.
|
||||||
|
|
||||||
1. Every repo must have a `Makefile`. See
|
1. Every repo must have a `Makefile`. See
|
||||||
[Repository Policies](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/REPO_POLICIES.md)
|
[Repository Policies](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/REPO_POLICIES.md)
|
||||||
@@ -124,20 +134,33 @@ last_modified: 2026-02-22
|
|||||||
|
|
||||||
1. Keep the `main()` function as small as possible.
|
1. Keep the `main()` function as small as possible.
|
||||||
|
|
||||||
1. Keep the `main` package as small as possible. Move as much code as is
|
1. Keep the `main` package as small as possible. Each `cmd/<name>/` directory
|
||||||
feasible to a library package, even if it's an internal one. `main` is just
|
contains a single `main.go` whose body is one call into library code (for
|
||||||
an entrypoint to your code, not a place for implementations. Exception:
|
example `os.Exit(cli.Main())` calling `internal/cli`). All CLI logic — flag
|
||||||
single-file scripts.
|
parsing, subcommand dispatch, argument handling, output formatting — lives
|
||||||
|
in `internal/` or `pkg/`, not in `cmd/`. `main` is just an entrypoint to
|
||||||
|
your code, not a place for implementations. Exception: single-file scripts.
|
||||||
|
|
||||||
|
1. No project logic outside `internal/` or `pkg/`. Anything in `cmd/` is a thin
|
||||||
|
entrypoint only.
|
||||||
|
|
||||||
1. HTTP HandleFuncs should be returned from methods or functions that need to
|
1. HTTP HandleFuncs should be returned from methods or functions that need to
|
||||||
handle HTTP requests. Don't use methods or your top level functions as
|
handle HTTP requests. Don't use methods or your top level functions as
|
||||||
handlers.
|
handlers.
|
||||||
|
|
||||||
1. Provide a .gitignore file that ignores at least `*.log`, `*.out`, and
|
1. The repository's own entries at the end of `.gitignore`, which a re-vendor
|
||||||
`*.test` files, as well as any binaries.
|
keeps, ignore at least `*.log`, `*.out`, and `*.test` files, as well as any
|
||||||
|
binaries.
|
||||||
|
|
||||||
1. Constructors should be called `New()` whenever possible. `modulename.New()`
|
1. Constructors **must** be called `New()`. `modulename.New()` works great if
|
||||||
works great if you name the packages properly.
|
you name the packages properly. If the constructor creates an instance from
|
||||||
|
an existing value or representation, `From<Something>()` (e.g.
|
||||||
|
`FromBytes()`, `FromConfig()`) is also acceptable. If the package contains
|
||||||
|
multiple types and `New()` is ambiguous, `NewThing()` is occasionally
|
||||||
|
acceptable — but prefer restructuring packages so each type gets its own
|
||||||
|
package and a plain `New()`. Do not invent creative constructor names like
|
||||||
|
`Create()`, `Make()`, `Build()`, `Open()` (unless wrapping an OS resource),
|
||||||
|
or `Init()`. If you see a constructor with a non-standard name, rename it.
|
||||||
|
|
||||||
1. Don't make packages too big. Break them up.
|
1. Don't make packages too big. Break them up.
|
||||||
|
|
||||||
@@ -149,9 +172,15 @@ last_modified: 2026-02-22
|
|||||||
1. Use descriptive names for modules and filenames. Avoid generic names like
|
1. Use descriptive names for modules and filenames. Avoid generic names like
|
||||||
`server`. `util` is banned.
|
`server`. `util` is banned.
|
||||||
|
|
||||||
1. Constructors should take a Params struct if they need more than 1-2
|
1. Constructors **must** take a `Params` struct (or `ThingParams` when
|
||||||
arguments. Positional arguments are an endless source of bugs and should be
|
`NewThing()` is used), even for a single argument. Named fields in a Params
|
||||||
avoided whenever possible.
|
struct are always clearer than positional arguments. Positional arguments
|
||||||
|
for constructors are an endless source of bugs — they make call sites
|
||||||
|
unreadable, invite wrong-order errors that the compiler can't catch when
|
||||||
|
types coincide, and force every caller to update when a new field is added.
|
||||||
|
The only exception is when the single argument is stupidly obvious from
|
||||||
|
context — e.g. `featureflag.New(true)` or `thing.NewFromReader(r)`. When in
|
||||||
|
doubt, use a Params struct.
|
||||||
|
|
||||||
1. Use `context.Context` for all functions that need it. If you don't need it,
|
1. Use `context.Context` for all functions that need it. If you don't need it,
|
||||||
you can pass `context.Background()`. Anything long-running should get and
|
you can pass `context.Background()`. Anything long-running should get and
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
title: Existing Repo Checklist
|
title: Existing Repo Checklist
|
||||||
last_modified: 2026-02-22
|
last_modified: 2026-10-06
|
||||||
---
|
---
|
||||||
|
|
||||||
Use this checklist when beginning work in a repo that may not yet conform to our
|
Use this checklist when beginning work in a repo that may not yet conform to our
|
||||||
@@ -24,20 +24,93 @@ with your task.
|
|||||||
- [ ] `LICENSE` file exists and matches the README
|
- [ ] `LICENSE` file exists and matches the README
|
||||||
- [ ] `REPO_POLICIES.md` exists and version date is current — fetch from
|
- [ ] `REPO_POLICIES.md` exists and version date is current — fetch from
|
||||||
`https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/REPO_POLICIES.md`
|
`https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/REPO_POLICIES.md`
|
||||||
- [ ] `.gitignore` is comprehensive (OS, editor, language artifacts, secrets) —
|
- [ ] Guidance for coding agents, if the repo has any, is one `AGENTS.md` at the
|
||||||
fetch from `https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore`
|
root — never a file or directory named after one agent tool, such as
|
||||||
if missing
|
`CLAUDE.md` or `.claude/`, and never separate memory files. Move what any
|
||||||
|
such committed file says into `AGENTS.md` and delete it.
|
||||||
|
- [ ] `.gitignore` is comprehensive (OS, editor, agent scratch, secrets, the
|
||||||
|
repo's own build outputs) — fetch from
|
||||||
|
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` if missing.
|
||||||
|
An existing repo usually has a hand-written one that is never re-fetched,
|
||||||
|
so check the entries rather than the file's presence. The file is the
|
||||||
|
canonical content followed by the repo's own entries, such as its
|
||||||
|
binaries; a re-vendor replaces the canonical part and keeps those entries.
|
||||||
- [ ] `.editorconfig` exists — fetch from
|
- [ ] `.editorconfig` exists — fetch from
|
||||||
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.editorconfig`
|
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.editorconfig`. The
|
||||||
- [ ] `Dockerfile` and `.dockerignore` exist; Dockerfile runs `make check` as a
|
file is the canonical content followed by the repo's own sections, such as
|
||||||
build step — fetch `.dockerignore` from
|
one for another language it uses; a re-vendor replaces the canonical part
|
||||||
|
and keeps those sections.
|
||||||
|
- [ ] `Dockerfile` and `.dockerignore` exist; the Dockerfile carries a `lint`
|
||||||
|
phase and a `test` phase, and the final stage carries a `COPY --from=` of
|
||||||
|
a harmless file from each — fetch `.dockerignore` from
|
||||||
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.dockerignore`
|
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.dockerignore`
|
||||||
- [ ] Gitea Actions workflow in `.gitea/workflows/` runs `docker build .` on
|
- [ ] Nothing has been appended after the final stage, and the gate phases are
|
||||||
push — reference
|
reachable from it. A stage nothing depends on is built only when
|
||||||
|
`--target` names it, so a lost `COPY --from=` edge leaves `docker build .`
|
||||||
|
passing while the gate never runs. Confirm by planting a violation, not by
|
||||||
|
reading the file.
|
||||||
|
- [ ] The gate phases invoke their tools directly, never through `make lint` or
|
||||||
|
`script/test` — those are themselves a `docker build` and would recurse
|
||||||
|
inside a build step
|
||||||
|
- [ ] Every depth-independent pattern in `.dockerignore` carries a `**/` prefix,
|
||||||
|
only genuinely root-anchored entries such as `.claude` are unprefixed, and
|
||||||
|
`.gitignore`'s patterns have not been transplanted unmodified — the
|
||||||
|
transplanted form leaves `config/.env` and `certs/server.key` in the build
|
||||||
|
context while reading as solved
|
||||||
|
- [ ] `.dockerignore` excludes the repo's own host-built artifacts (compiled
|
||||||
|
binaries, test binaries, coverage output), written root-anchored —
|
||||||
|
`/myapp`, never `**/myapp`. An existing repo is where such a binary is
|
||||||
|
likeliest to already be sitting in the build context, invisible to git.
|
||||||
|
- [ ] `.claude/` is in `.gitignore` (unanchored) and `.claude` in
|
||||||
|
`.dockerignore` (anchored, no `**/` prefix). Agent worktrees are entire
|
||||||
|
checkouts of the repo, so they inflate the context by a multiple of it and
|
||||||
|
can copy another session's unreviewed work into an image layer. If agents
|
||||||
|
here run anywhere other than the repo root, the anchored entry misses
|
||||||
|
`services/api/.claude/`: add anchored entries for those directories.
|
||||||
|
- [ ] If the repo embeds a version in a binary: `.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=...`. `script/docker` and `script/cibuild` already
|
||||||
|
pass the version they compute on the host; it takes precedence. The
|
||||||
|
canonical `.gitea/workflows/check.yml` sets `fetch-depth: 0` on its
|
||||||
|
checkout step, which otherwise clones shallow and fetches no tags, so a CI
|
||||||
|
build finds the tag too.
|
||||||
|
- [ ] Gitea Actions workflow in `.gitea/workflows/` runs `script/cibuild` on
|
||||||
|
push, checks out with `persist-credentials: false` and with
|
||||||
|
`fetch-depth: 0` (which fetches the tags `git describe` needs), and
|
||||||
|
carries the `concurrency` block that lets a new push cancel only the same
|
||||||
|
branch's older run — reference
|
||||||
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitea/workflows/check.yml`
|
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitea/workflows/check.yml`
|
||||||
- [ ] Language-specific config:
|
- [ ] Language-specific config:
|
||||||
- [ ] Go: `go.mod`, `go.sum`, `.golangci.yml` (fetch from
|
- [ ] Go: `go.mod`, `go.sum`, `.golangci.yml` (fetch from
|
||||||
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml`)
|
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml` and,
|
||||||
|
in the same commit, set the lint phase digest to the one named in the
|
||||||
|
`.golangci.yml` paragraph of `REPO_POLICIES.md`)
|
||||||
- [ ] JS: `package.json`, `yarn.lock`, `.prettierrc`, `.prettierignore`
|
- [ ] JS: `package.json`, `yarn.lock`, `.prettierrc`, `.prettierignore`
|
||||||
(fetch from
|
(fetch from
|
||||||
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.prettierrc` and
|
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.prettierrc` and
|
||||||
@@ -45,14 +118,46 @@ with your task.
|
|||||||
- [ ] Python: `pyproject.toml`
|
- [ ] Python: `pyproject.toml`
|
||||||
- [ ] Docs/writing: `.prettierrc`, `.prettierignore` (same URLs as above)
|
- [ ] Docs/writing: `.prettierrc`, `.prettierignore` (same URLs as above)
|
||||||
|
|
||||||
# Makefile
|
# Makefile and script/ Entrypoints
|
||||||
|
|
||||||
- [ ] `Makefile` exists in root — reference
|
- [ ] `Makefile` exists in root — reference
|
||||||
`https://git.eeqj.de/sneak/prompts/raw/branch/main/Makefile`
|
`https://git.eeqj.de/sneak/prompts/raw/branch/main/Makefile`
|
||||||
- [ ] Has targets: `test`, `lint`, `fmt`, `fmt-check`, `check`, `docker`,
|
- [ ] Has targets: `test`, `lint`, `fmt`, `fmt-check`, `check`, `docker`,
|
||||||
`hooks`
|
`hooks`
|
||||||
|
- [ ] Target implementations live in `script/` (scripts-to-rule-them-all);
|
||||||
|
Makefile targets are thin shims calling them — model scripts at
|
||||||
|
`https://git.eeqj.de/sneak/prompts/raw/branch/main/script/<name>`
|
||||||
|
- [ ] `script/precommit` exists and the pre-commit hook (installed by
|
||||||
|
`script/install-precommit`, shimmed by `make hooks`) runs it
|
||||||
|
- [ ] README has an **Entrypoints** section documenting the `script/`
|
||||||
|
entrypoints and linking the standard
|
||||||
|
- [ ] `script/lint` and `script/test` build their phase by name
|
||||||
|
(`docker build --no-cache --target <phase> -t <name>-<phase> .`), and no
|
||||||
|
host invocation anywhere in the repo can produce a lint verdict — grep for
|
||||||
|
the linter's own name across `script/`, the `Makefile` and CI config, not
|
||||||
|
just `script/lint`. A second path is likeliest here: a `make lint-fast`,
|
||||||
|
an older host-versus-container branch, or a CI step calling the binary
|
||||||
|
directly. `script/fmt` and `script/fmt-check` are expected hits and stay
|
||||||
|
on the host.
|
||||||
|
- [ ] Every `docker build` in `script/` is tagged — an untagged one leaves a
|
||||||
|
dangling image behind on every run, on every host and CI runner
|
||||||
|
- [ ] `script/cibuild` runs `script/bootstrap` before `script/check`, and builds
|
||||||
|
the image with `--no-cache`. Without the bootstrap the CI run dies in
|
||||||
|
`script/fmt-check`, which runs the formatter on the host and finds nothing
|
||||||
|
installed.
|
||||||
|
- [ ] `script/fmt` and `script/fmt-check` source nvm for the pinned node version
|
||||||
|
before invoking `yarn`, as `script/bootstrap`'s own install step does.
|
||||||
|
`script/bootstrap` leaves the node and yarn it installs off the `PATH` of
|
||||||
|
the shell that called it, so a bare `yarn` exits 127 on a runner carrying
|
||||||
|
nothing but docker and git.
|
||||||
|
- [ ] `script/bootstrap` installs no linter of its own — delete the block, its
|
||||||
|
version variables and its call site. A JS repo's `yarn install` stays; it
|
||||||
|
brings a linter along with every other dependency, and no verdict is taken
|
||||||
|
from it.
|
||||||
- [ ] `make check` does not modify any files in the repo
|
- [ ] `make check` does not modify any files in the repo
|
||||||
- [ ] `make test` has a 30-second timeout
|
- [ ] `make test` has a 90-second timeout and completes within the 60-second
|
||||||
|
hard cap (over 20 seconds is green but must be filed as an improvement
|
||||||
|
bug)
|
||||||
- [ ] `make test` runs real tests, not a no-op (at minimum, import/compile
|
- [ ] `make test` runs real tests, not a no-op (at minimum, import/compile
|
||||||
check)
|
check)
|
||||||
- [ ] `make check` passes on current branch
|
- [ ] `make check` passes on current branch
|
||||||
@@ -78,8 +183,29 @@ with your task.
|
|||||||
`internal/`, `static/`, etc.)
|
`internal/`, `static/`, etc.)
|
||||||
- [ ] Go migrations in `internal/db/migrations/` and embedded in binary
|
- [ ] Go migrations in `internal/db/migrations/` and embedded in binary
|
||||||
|
|
||||||
|
# HTTP Service Hardening (if targeting 1.0 and the repo is an HTTP/web service)
|
||||||
|
|
||||||
|
- [ ] Security headers set on all responses (HSTS, CSP, X-Frame-Options,
|
||||||
|
X-Content-Type-Options, Referrer-Policy, Permissions-Policy)
|
||||||
|
- [ ] Request body size limits enforced on all endpoints
|
||||||
|
- [ ] Read/write/idle timeouts configured on the HTTP server (slowloris defense)
|
||||||
|
- [ ] Per-handler execution time limits in place
|
||||||
|
- [ ] Password-based auth endpoints are rate-limited
|
||||||
|
- [ ] CSRF tokens on all state-mutating HTML forms
|
||||||
|
- [ ] Passwords hashed with bcrypt, scrypt, or argon2
|
||||||
|
- [ ] Session cookies use HttpOnly, Secure, and SameSite attributes
|
||||||
|
- [ ] True client IP correctly detected behind reverse proxy (trusted proxy
|
||||||
|
allowlist configured)
|
||||||
|
- [ ] CORS restricted to explicit origin allowlist for authenticated endpoints
|
||||||
|
- [ ] Error responses do not leak stack traces, SQL queries, or internal paths
|
||||||
|
|
||||||
# Final
|
# Final
|
||||||
|
|
||||||
- [ ] `make check` passes
|
- [ ] `make check` passes
|
||||||
- [ ] `docker build` succeeds
|
- [ ] `script/cibuild` succeeds in a fresh clone on a host carrying nothing but
|
||||||
|
docker and git, with no node or yarn on `PATH`, which is what CI has, and
|
||||||
|
demonstrably executed the checks — a sub-second build, or `CACHED` on a
|
||||||
|
gate layer, means nothing ran
|
||||||
|
- [ ] A planted lint violation fails both `make lint` and a plain
|
||||||
|
`docker build .`; revert it afterwards
|
||||||
- [ ] Commit and merge fixes before starting your actual task
|
- [ ] Commit and merge fixes before starting your actual task
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
title: Go HTTP Server Conventions
|
title: Go HTTP Server Conventions
|
||||||
last_modified: 2026-02-22
|
last_modified: 2026-10-04
|
||||||
---
|
---
|
||||||
|
|
||||||
This document defines the architectural patterns, design decisions, and
|
This document defines the architectural patterns, design decisions, and
|
||||||
@@ -106,6 +106,9 @@ project-root/
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
"yourproject/internal/config"
|
"yourproject/internal/config"
|
||||||
"yourproject/internal/database"
|
"yourproject/internal/database"
|
||||||
"yourproject/internal/globals"
|
"yourproject/internal/globals"
|
||||||
@@ -120,13 +123,14 @@ import (
|
|||||||
var (
|
var (
|
||||||
Appname string = "CHANGEME"
|
Appname string = "CHANGEME"
|
||||||
Version string
|
Version string
|
||||||
Buildarch string
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
globals.Appname = Appname
|
globals.Appname = Appname
|
||||||
globals.Version = Version
|
globals.Version = Version
|
||||||
globals.Buildarch = Buildarch
|
|
||||||
|
// A write to a closed stdout or stderr must not end the process.
|
||||||
|
signal.Ignore(syscall.SIGPIPE)
|
||||||
|
|
||||||
fx.New(
|
fx.New(
|
||||||
fx.Provide(
|
fx.Provide(
|
||||||
@@ -200,7 +204,8 @@ Providers are resolved automatically by fx, but conceptually follow this order:
|
|||||||
Database)
|
Database)
|
||||||
6. `middleware.New` - Middleware (depends on Logger, Globals, Config)
|
6. `middleware.New` - Middleware (depends on Logger, Globals, Config)
|
||||||
7. `handlers.New` - Handlers (depends on Logger, Globals, Database, Healthcheck)
|
7. `handlers.New` - Handlers (depends on Logger, Globals, Database, Healthcheck)
|
||||||
8. `server.New` - Server (depends on all above)
|
8. `server.New` - Server (depends on all above, and on `fx.Shutdowner`, which fx
|
||||||
|
provides itself)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -219,16 +224,14 @@ type ServerParams struct {
|
|||||||
Config *config.Config
|
Config *config.Config
|
||||||
Middleware *middleware.Middleware
|
Middleware *middleware.Middleware
|
||||||
Handlers *handlers.Handlers
|
Handlers *handlers.Handlers
|
||||||
|
Shutdowner fx.Shutdowner
|
||||||
}
|
}
|
||||||
|
|
||||||
type Server struct {
|
type Server struct {
|
||||||
startupTime time.Time
|
startupTime time.Time
|
||||||
port int
|
port int
|
||||||
exitCode int
|
|
||||||
sentryEnabled bool
|
sentryEnabled bool
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
ctx context.Context
|
|
||||||
cancelFunc context.CancelFunc
|
|
||||||
httpServer *http.Server
|
httpServer *http.Server
|
||||||
router *chi.Mux
|
router *chi.Mux
|
||||||
params ServerParams
|
params ServerParams
|
||||||
@@ -250,13 +253,15 @@ func New(lc fx.Lifecycle, params ServerParams) (*Server, error) {
|
|||||||
lc.Append(fx.Hook{
|
lc.Append(fx.Hook{
|
||||||
OnStart: func(ctx context.Context) error {
|
OnStart: func(ctx context.Context) error {
|
||||||
s.startupTime = time.Now()
|
s.startupTime = time.Now()
|
||||||
go s.Run()
|
if err := s.enableSentry(); err != nil {
|
||||||
return nil
|
return err
|
||||||
},
|
}
|
||||||
OnStop: func(ctx context.Context) error {
|
s.SetupRoutes()
|
||||||
// Server shutdown logic
|
s.httpServer = s.newHTTPServer()
|
||||||
|
go s.serveUntilShutdown()
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
|
OnStop: s.cleanShutdown,
|
||||||
})
|
})
|
||||||
return s, nil
|
return s, nil
|
||||||
}
|
}
|
||||||
@@ -266,23 +271,25 @@ func New(lc fx.Lifecycle, params ServerParams) (*Server, error) {
|
|||||||
|
|
||||||
```go
|
```go
|
||||||
// internal/server/http.go
|
// internal/server/http.go
|
||||||
func (s *Server) serveUntilShutdown() {
|
func (s *Server) newHTTPServer() *http.Server {
|
||||||
listenAddr := fmt.Sprintf(":%d", s.params.Config.Port)
|
return &http.Server{
|
||||||
s.httpServer = &http.Server{
|
Addr: fmt.Sprintf(":%d", s.params.Config.Port),
|
||||||
Addr: listenAddr,
|
|
||||||
ReadTimeout: 10 * time.Second,
|
ReadTimeout: 10 * time.Second,
|
||||||
WriteTimeout: 10 * time.Second,
|
WriteTimeout: 10 * time.Second,
|
||||||
MaxHeaderBytes: 1 << 20,
|
MaxHeaderBytes: 1 << 20,
|
||||||
Handler: s,
|
Handler: s,
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
s.SetupRoutes()
|
// serveUntilShutdown returns when the stop hook shuts the HTTP server down.
|
||||||
|
// If it stops for any other reason, such as its port being taken, it asks fx
|
||||||
s.log.Info("http begin listen", "listenaddr", listenAddr)
|
// to shut down with exit code 1.
|
||||||
|
func (s *Server) serveUntilShutdown() {
|
||||||
|
s.log.Info("http begin listen", "listenaddr", s.httpServer.Addr)
|
||||||
if err := s.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
if err := s.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
s.log.Error("listen error", "error", err)
|
s.log.Error("listen error", "error", err)
|
||||||
if s.cancelFunc != nil {
|
if err := s.params.Shutdowner.Shutdown(fx.ExitCode(1)); err != nil {
|
||||||
s.cancelFunc()
|
s.log.Error("shutdown request failed", "error", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -294,43 +301,30 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
## Signal Handling and Graceful Shutdown
|
## Signal Handling and Graceful Shutdown
|
||||||
|
|
||||||
|
fx owns SIGINT, SIGTERM and the exit code. `Run()` in `main` waits for one of
|
||||||
|
those signals or for a call to `Shutdown()` on `fx.Shutdowner`, runs the stop
|
||||||
|
hooks, and exits 0 after a signal, or with the code the call gave in
|
||||||
|
`fx.ExitCode`. It exits 1 instead when a start hook or a stop hook returns an
|
||||||
|
error; when a start hook fails, fx first runs the stop hooks of everything
|
||||||
|
already started. No other code calls `signal.Notify` or `os.Exit`: the listen
|
||||||
|
error above asks fx to shut down with `fx.ExitCode(1)`, and a Sentry start
|
||||||
|
failure is returned from the start hook. Each component releases its own
|
||||||
|
resources in its own stop hook, which fx runs in the reverse order of start.
|
||||||
|
|
||||||
```go
|
```go
|
||||||
func (s *Server) serve() int {
|
// cleanShutdown is the server's stop hook. It fails when requests are still
|
||||||
s.ctx, s.cancelFunc = context.WithCancel(context.Background())
|
// running after 5 seconds.
|
||||||
|
func (s *Server) cleanShutdown(ctx context.Context) error {
|
||||||
// Signal watcher
|
ctxShutdown, shutdownCancel := context.WithTimeout(ctx, 5*time.Second)
|
||||||
go func() {
|
defer shutdownCancel()
|
||||||
c := make(chan os.Signal, 1)
|
err := s.httpServer.Shutdown(ctxShutdown)
|
||||||
signal.Ignore(syscall.SIGPIPE)
|
|
||||||
signal.Notify(c, os.Interrupt, syscall.SIGTERM)
|
|
||||||
sig := <-c
|
|
||||||
s.log.Info("signal received", "signal", sig)
|
|
||||||
if s.cancelFunc != nil {
|
|
||||||
s.cancelFunc()
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
go s.serveUntilShutdown()
|
|
||||||
|
|
||||||
for range s.ctx.Done() {
|
|
||||||
}
|
|
||||||
s.cleanShutdown()
|
|
||||||
return s.exitCode
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) cleanShutdown() {
|
|
||||||
s.exitCode = 0
|
|
||||||
ctxShutdown, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
||||||
if err := s.httpServer.Shutdown(ctxShutdown); err != nil {
|
|
||||||
s.log.Error("server clean shutdown failed", "error", err)
|
|
||||||
}
|
|
||||||
if shutdownCancel != nil {
|
|
||||||
shutdownCancel()
|
|
||||||
}
|
|
||||||
s.cleanupForExit()
|
|
||||||
if s.sentryEnabled {
|
if s.sentryEnabled {
|
||||||
sentry.Flush(2 * time.Second)
|
sentry.Flush(2 * time.Second)
|
||||||
}
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("http server shutdown: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -826,7 +820,7 @@ func (l *Logger) Identify() {
|
|||||||
l.log.Info("starting",
|
l.log.Info("starting",
|
||||||
"appname", l.params.Globals.Appname,
|
"appname", l.params.Globals.Appname,
|
||||||
"version", l.params.Globals.Version,
|
"version", l.params.Globals.Version,
|
||||||
"buildarch", l.params.Globals.Buildarch,
|
"arch", runtime.GOARCH,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -948,20 +942,17 @@ import "go.uber.org/fx"
|
|||||||
var (
|
var (
|
||||||
Appname string
|
Appname string
|
||||||
Version string
|
Version string
|
||||||
Buildarch string
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Struct for DI
|
// Struct for DI
|
||||||
type Globals struct {
|
type Globals struct {
|
||||||
Appname string
|
Appname string
|
||||||
Version string
|
Version string
|
||||||
Buildarch string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(lc fx.Lifecycle) (*Globals, error) {
|
func New(lc fx.Lifecycle) (*Globals, error) {
|
||||||
n := &Globals{
|
n := &Globals{
|
||||||
Appname: Appname,
|
Appname: Appname,
|
||||||
Buildarch: Buildarch,
|
|
||||||
Version: Version,
|
Version: Version,
|
||||||
}
|
}
|
||||||
return n, nil
|
return n, nil
|
||||||
@@ -975,13 +966,11 @@ func New(lc fx.Lifecycle) (*Globals, error) {
|
|||||||
var (
|
var (
|
||||||
Appname string = "CHANGEME" // Default, overridden by build
|
Appname string = "CHANGEME" // Default, overridden by build
|
||||||
Version string // Set at build time
|
Version string // Set at build time
|
||||||
Buildarch string // Set at build time
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
globals.Appname = Appname
|
globals.Appname = Appname
|
||||||
globals.Version = Version
|
globals.Version = Version
|
||||||
globals.Buildarch = Buildarch
|
|
||||||
// ...
|
// ...
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -991,11 +980,16 @@ func main() {
|
|||||||
Use ldflags to inject version information at build time:
|
Use ldflags to inject version information at build time:
|
||||||
|
|
||||||
```makefile
|
```makefile
|
||||||
VERSION := $(shell git describe --tags --always)
|
# ?= rather than := so that a `VERSION` build argument takes precedence:
|
||||||
BUILDARCH := $(shell go env GOARCH)
|
# where a build stage invokes make, `ARG VERSION` puts it in the
|
||||||
|
# environment and `?=` defers to it. Otherwise `git describe` runs, in a
|
||||||
|
# build stage on the `.git` the build context carries. When it prints
|
||||||
|
# nothing (outside a git checkout, or where git is missing or refuses the
|
||||||
|
# checkout), the version falls back to `dev`.
|
||||||
|
VERSION ?= $(or $(shell git describe --tags --always 2>/dev/null),dev)
|
||||||
|
|
||||||
build:
|
build:
|
||||||
go build -ldflags "-X main.Version=$(VERSION) -X main.Buildarch=$(BUILDARCH)" ./cmd/httpd
|
go build -ldflags "-X main.Version=$(VERSION)" ./cmd/httpd
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -1162,11 +1156,11 @@ s.router.Get("/.well-known/healthcheck", s.h.HandleHealthCheck())
|
|||||||
Sentry is conditionally enabled based on `SENTRY_DSN` environment variable:
|
Sentry is conditionally enabled based on `SENTRY_DSN` environment variable:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
func (s *Server) enableSentry() {
|
func (s *Server) enableSentry() error {
|
||||||
s.sentryEnabled = false
|
s.sentryEnabled = false
|
||||||
|
|
||||||
if s.params.Config.SentryDSN == "" {
|
if s.params.Config.SentryDSN == "" {
|
||||||
return
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
err := sentry.Init(sentry.ClientOptions{
|
err := sentry.Init(sentry.ClientOptions{
|
||||||
@@ -1174,15 +1168,17 @@ func (s *Server) enableSentry() {
|
|||||||
Release: fmt.Sprintf("%s-%s", s.params.Globals.Appname, s.params.Globals.Version),
|
Release: fmt.Sprintf("%s-%s", s.params.Globals.Appname, s.params.Globals.Version),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.log.Error("sentry init failure", "error", err)
|
return fmt.Errorf("sentry init failure: %w", err)
|
||||||
os.Exit(1)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
s.log.Info("sentry error reporting activated")
|
s.log.Info("sentry error reporting activated")
|
||||||
s.sentryEnabled = true
|
s.sentryEnabled = true
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The server's start hook calls `enableSentry()` and returns its error, so a DSN
|
||||||
|
Sentry rejects stops startup and fx exits 1.
|
||||||
|
|
||||||
Sentry middleware with repanic (bubbles panics to chi's Recoverer):
|
Sentry middleware with repanic (bubbles panics to chi's Recoverer):
|
||||||
|
|
||||||
```go
|
```go
|
||||||
@@ -1194,7 +1190,7 @@ if s.sentryEnabled {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Flush Sentry on shutdown:
|
Flush Sentry in the server's stop hook, `cleanShutdown()`:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
if s.sentryEnabled {
|
if s.sentryEnabled {
|
||||||
|
|||||||
@@ -0,0 +1,449 @@
|
|||||||
|
# LLM Prose Tells
|
||||||
|
|
||||||
|
A catalog of patterns found in LLM-generated prose.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sentence Structure
|
||||||
|
|
||||||
|
### The Em-Dash Pivot: "Not X—but Y"
|
||||||
|
|
||||||
|
A negation followed by an em-dash and a reframe.
|
||||||
|
|
||||||
|
> "It's not just a tool—it's a paradigm shift." "This isn't about
|
||||||
|
> technology—it's about trust."
|
||||||
|
|
||||||
|
### Em-Dash Overuse Generally
|
||||||
|
|
||||||
|
Even outside the "not X but Y" pivot, models substitute em-dashes for commas,
|
||||||
|
semicolons, parentheses, colons, and periods. The em-dash can replace any other
|
||||||
|
punctuation mark, so models default to it.
|
||||||
|
|
||||||
|
### The Colon Elaboration
|
||||||
|
|
||||||
|
A short declarative clause, then a colon, then a longer explanation.
|
||||||
|
|
||||||
|
> "The answer is simple: we need to rethink our approach from the ground up."
|
||||||
|
|
||||||
|
### The Triple Construction
|
||||||
|
|
||||||
|
> "It's fast, it's scalable, and it's open source."
|
||||||
|
|
||||||
|
Three parallel items in a list, usually escalating. Always exactly three (rarely
|
||||||
|
two, never four) with strict grammatical parallelism.
|
||||||
|
|
||||||
|
### The Staccato Burst
|
||||||
|
|
||||||
|
> "This matters. It always has. And it always will." "The data is clear. The
|
||||||
|
> trend is undeniable. The conclusion is obvious."
|
||||||
|
|
||||||
|
Runs of very short sentences at the same cadence and matching length.
|
||||||
|
|
||||||
|
### The Two-Clause Compound Sentence
|
||||||
|
|
||||||
|
An independent clause, a comma, a conjunction ("and," "but," "which,"
|
||||||
|
"because"), and a second independent clause of similar length. Every sentence
|
||||||
|
becomes two balanced halves.
|
||||||
|
|
||||||
|
> "The construction itself is perfectly normal, which is why the frequency is
|
||||||
|
> what gives it away." "They contain zero information, and the actual point
|
||||||
|
> always comes in the paragraph that follows them." "The qualifier never changes
|
||||||
|
> the argument that follows it, and its purpose is to perform nuance rather than
|
||||||
|
> to express an actual reservation."
|
||||||
|
|
||||||
|
Human prose has sentences with one clause, sentences with three, sentences that
|
||||||
|
start with a subordinate clause before reaching the main one, sentences that
|
||||||
|
embed their complexity in the middle.
|
||||||
|
|
||||||
|
### Uniform Sentences Per Paragraph
|
||||||
|
|
||||||
|
Model-generated paragraphs contain between three and five sentences, a count
|
||||||
|
that holds steady across a piece. If the first paragraph has four sentences,
|
||||||
|
every subsequent paragraph will too.
|
||||||
|
|
||||||
|
### The Dramatic Fragment
|
||||||
|
|
||||||
|
Sentence fragments used as standalone paragraphs for emphasis.
|
||||||
|
|
||||||
|
> "Full stop." "Let that sink in."
|
||||||
|
|
||||||
|
### The Pivot Paragraph
|
||||||
|
|
||||||
|
> "But here's where it gets interesting." "Which raises an uncomfortable truth."
|
||||||
|
|
||||||
|
One-sentence paragraphs that exist only to transition between ideas, containing
|
||||||
|
zero information. The actual point is always in the next paragraph.
|
||||||
|
|
||||||
|
### The Parenthetical Qualifier
|
||||||
|
|
||||||
|
> "This is, of course, a simplification." "There are, to be fair, exceptions."
|
||||||
|
|
||||||
|
Parenthetical asides inserted to perform nuance without changing the argument.
|
||||||
|
|
||||||
|
### The Unnecessary Contrast
|
||||||
|
|
||||||
|
A contrasting clause appended to a statement that doesn't need one, using
|
||||||
|
"whereas," "as opposed to," "unlike," or "except that."
|
||||||
|
|
||||||
|
> "Models write one register above where a human would, whereas human writers
|
||||||
|
> tend to match register to context."
|
||||||
|
|
||||||
|
The contrasting clause restates what the first clause already said. If you
|
||||||
|
delete the "whereas" clause and the sentence still says everything it needs to,
|
||||||
|
the contrast was filler.
|
||||||
|
|
||||||
|
### Unnecessary Elaboration
|
||||||
|
|
||||||
|
Models keep going after the sentence has already made its point.
|
||||||
|
|
||||||
|
> "A person might lean on one or two of these habits across an entire essay, but
|
||||||
|
> LLM output will use fifteen of them per paragraph, consistently, throughout
|
||||||
|
> the entire piece."
|
||||||
|
|
||||||
|
This sentence could end at "paragraph." The words after it repeat what "per
|
||||||
|
paragraph" already means. If you can cut the last third of a sentence without
|
||||||
|
losing meaning, the last third shouldn't be there.
|
||||||
|
|
||||||
|
### The Question-Then-Answer
|
||||||
|
|
||||||
|
> "So what does this mean for the average user? It means everything."
|
||||||
|
|
||||||
|
A rhetorical question immediately followed by its own answer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Word Choice
|
||||||
|
|
||||||
|
### Overused Intensifiers
|
||||||
|
|
||||||
|
"Crucial," "vital," "robust," "comprehensive," "fundamental," "arguably,"
|
||||||
|
"straightforward," "noteworthy," "realm," "landscape," "leverage" (as a verb),
|
||||||
|
"delve," "tapestry," "multifaceted," "nuanced" (applied to the model's own
|
||||||
|
analysis), "pivotal," "unprecedented" (applied to things with plenty of
|
||||||
|
precedent), "navigate," "foster," "underscores," "resonates," "embark,"
|
||||||
|
"streamline," "spearhead."
|
||||||
|
|
||||||
|
### Elevated Register Drift
|
||||||
|
|
||||||
|
Models write one register above where a human would, replacing "use" with
|
||||||
|
"utilize," "start" with "commence," "help" with "facilitate," "show" with
|
||||||
|
"demonstrate," "try" with "endeavor," "change" with "transform," and "make" with
|
||||||
|
"craft."
|
||||||
|
|
||||||
|
### Filler Adverbs
|
||||||
|
|
||||||
|
"Importantly," "essentially," "fundamentally," "ultimately," "inherently,"
|
||||||
|
"particularly," "increasingly." Dropped in to signal that something matters when
|
||||||
|
the writing itself should make the importance clear.
|
||||||
|
|
||||||
|
### The "Almost" Hedge
|
||||||
|
|
||||||
|
Instead of saying a pattern "always" or "never" does something, models write
|
||||||
|
"almost always," "almost never," "almost certainly," "almost exclusively." A
|
||||||
|
micro-hedge, less obvious than the full hedge stack.
|
||||||
|
|
||||||
|
### "In an era of..."
|
||||||
|
|
||||||
|
> "In an era of rapid technological change..."
|
||||||
|
|
||||||
|
Used to open an essay. The model is stalling while it figures out what the
|
||||||
|
actual argument is.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rhetorical Patterns
|
||||||
|
|
||||||
|
### The Balanced Take
|
||||||
|
|
||||||
|
> "While X has its drawbacks, it also offers significant benefits."
|
||||||
|
|
||||||
|
Every argument followed by a concession, every criticism softened. A direct
|
||||||
|
artifact of RLHF training, which penalizes strong stances.
|
||||||
|
|
||||||
|
### The Throat-Clearing Opener
|
||||||
|
|
||||||
|
> "In today's rapidly evolving digital landscape, the question of data privacy
|
||||||
|
> has never been more important."
|
||||||
|
|
||||||
|
The first paragraph adds no information. Delete it and the piece improves.
|
||||||
|
|
||||||
|
### The False Conclusion
|
||||||
|
|
||||||
|
> "At the end of the day, what matters most is..." "Moving forward, we must..."
|
||||||
|
|
||||||
|
The high school "In conclusion,..." dressed up for a professional audience.
|
||||||
|
|
||||||
|
### The Sycophantic Frame
|
||||||
|
|
||||||
|
> "Great question!" "That's a really insightful observation."
|
||||||
|
|
||||||
|
No one who writes for a living opens by complimenting the assignment.
|
||||||
|
|
||||||
|
### The Listicle Instinct
|
||||||
|
|
||||||
|
Models default to numbered or bulleted lists even when prose would be more
|
||||||
|
appropriate. The lists contain exactly 3, 5, 7, or 10 items (never 4, 6, or 9),
|
||||||
|
use rigidly parallel grammar, and get introduced with a preamble like "Here are
|
||||||
|
the key considerations:"
|
||||||
|
|
||||||
|
### The Hedge Stack
|
||||||
|
|
||||||
|
> "It's worth noting that, while this may not be universally applicable, in many
|
||||||
|
> cases it can potentially offer significant benefits."
|
||||||
|
|
||||||
|
Five hedges in one sentence ("worth noting," "while," "may not be," "in many
|
||||||
|
cases," "can potentially"), communicating nothing.
|
||||||
|
|
||||||
|
### The Empathy Performance
|
||||||
|
|
||||||
|
> "This can be a deeply challenging experience." "Your feelings are valid."
|
||||||
|
|
||||||
|
Generic emotional language that could apply to anything.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Structural Tells
|
||||||
|
|
||||||
|
### Symmetrical Section Length
|
||||||
|
|
||||||
|
If the first section runs about 150 words, every subsequent section will fall
|
||||||
|
between 130 and 170.
|
||||||
|
|
||||||
|
### The Five-Paragraph Prison
|
||||||
|
|
||||||
|
Model essays follow a rigid introduction-body-conclusion arc even when nobody
|
||||||
|
asked for one. The introduction previews the argument, the body presents 3 to 5
|
||||||
|
points, the conclusion restates the thesis.
|
||||||
|
|
||||||
|
### Connector Addiction
|
||||||
|
|
||||||
|
The first word of each paragraph forms an unbroken chain of transition words:
|
||||||
|
"However," "Furthermore," "Moreover," "Additionally," "That said," "To that
|
||||||
|
end," "With that in mind," "Building on this."
|
||||||
|
|
||||||
|
### Absence of Mess
|
||||||
|
|
||||||
|
Model prose doesn't contradict itself mid-paragraph and then catch the
|
||||||
|
contradiction, go on a tangent and have to walk it back, use an obscure idiom
|
||||||
|
without explaining it, make a joke that risks falling flat, leave a thought
|
||||||
|
genuinely unfinished, or keep a sentence the writer liked the sound of even
|
||||||
|
though it doesn't quite work.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Framing Tells
|
||||||
|
|
||||||
|
### "Broader Implications"
|
||||||
|
|
||||||
|
> "This has implications far beyond just the tech industry."
|
||||||
|
|
||||||
|
Zooming out to claim broader significance without substantiating it.
|
||||||
|
|
||||||
|
### "It's important to note that..."
|
||||||
|
|
||||||
|
This phrase and its variants ("it's worth noting," "it bears mentioning," "it
|
||||||
|
should be noted") function as verbal tics before a qualification the model
|
||||||
|
believes someone expects.
|
||||||
|
|
||||||
|
### The Metaphor Crutch
|
||||||
|
|
||||||
|
Models rely on a small, predictable set of metaphors: "double-edged sword," "tip
|
||||||
|
of the iceberg," "north star," "building blocks," "elephant in the room,"
|
||||||
|
"perfect storm," "game-changer."
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Copyediting Checklist: Removing LLM Tells
|
||||||
|
|
||||||
|
Follow this checklist when editing any document to remove machine-generated
|
||||||
|
patterns. Do at least two full passes, because fixing one pattern often
|
||||||
|
introduces another.
|
||||||
|
|
||||||
|
### Pass 1: Word-Level Cleanup
|
||||||
|
|
||||||
|
1. Search the document for every word in the overused intensifiers list
|
||||||
|
("crucial," "vital," "robust," "comprehensive," "fundamental," "arguably,"
|
||||||
|
"straightforward," "noteworthy," "realm," "landscape," "leverage," "delve,"
|
||||||
|
"tapestry," "multifaceted," "nuanced," "pivotal," "unprecedented,"
|
||||||
|
"navigate," "foster," "underscores," "resonates," "embark," "streamline,"
|
||||||
|
"spearhead") and replace each one with a plainer word, or delete it if the
|
||||||
|
sentence works without it.
|
||||||
|
|
||||||
|
2. Search for filler adverbs ("importantly," "essentially," "fundamentally,"
|
||||||
|
"ultimately," "inherently," "particularly," "increasingly") and delete every
|
||||||
|
instance where the sentence still makes sense without it.
|
||||||
|
|
||||||
|
3. Look for elevated register drift ("utilize," "commence," "facilitate,"
|
||||||
|
"demonstrate," "endeavor," "transform," "craft" and similar) and replace with
|
||||||
|
the simpler word.
|
||||||
|
|
||||||
|
4. Search for "it's important to note," "it's worth noting," "it bears
|
||||||
|
mentioning," and "it should be noted" and delete the phrase in every case.
|
||||||
|
|
||||||
|
5. Search for the stock metaphors ("double-edged sword," "tip of the iceberg,"
|
||||||
|
"north star," "building blocks," "elephant in the room," "perfect storm,"
|
||||||
|
"game-changer," "at the end of the day") and replace them with something
|
||||||
|
specific to the topic, or just state the point directly.
|
||||||
|
|
||||||
|
6. Search for "almost" used as a hedge ("almost always," "almost never," "almost
|
||||||
|
certainly," "almost exclusively") and decide in each case whether to commit
|
||||||
|
to the unqualified claim or to drop the sentence entirely.
|
||||||
|
|
||||||
|
7. Search for em-dashes and replace each one with the punctuation mark that
|
||||||
|
would normally be used in that position (comma, semicolon, colon, period, or
|
||||||
|
parentheses). If you can't identify which one it should be, the sentence
|
||||||
|
needs to be restructured.
|
||||||
|
|
||||||
|
8. Remove redundant adjectives. For each adjective, ask whether the sentence
|
||||||
|
changes meaning without it. "A single paragraph" means the same as "a
|
||||||
|
paragraph." "An entire essay" means the same as "an essay." If the adjective
|
||||||
|
doesn't change the meaning, cut it.
|
||||||
|
|
||||||
|
9. Remove unnecessary trailing clauses. Read the end of each sentence and ask
|
||||||
|
whether the last clause restates what the sentence already said. If so, end
|
||||||
|
the sentence earlier.
|
||||||
|
|
||||||
|
### Pass 2: Sentence-Level Restructuring
|
||||||
|
|
||||||
|
10. Find every em-dash pivot ("not X—but Y," "not just X—Y," "more than X—Y")
|
||||||
|
and rewrite it as two separate clauses or a single sentence that makes the
|
||||||
|
point without the negation-then-correction structure.
|
||||||
|
|
||||||
|
11. Find every colon elaboration and check whether it's doing real work. If the
|
||||||
|
clause before the colon could be deleted without losing meaning, rewrite the
|
||||||
|
sentence to start with the substance that comes after the colon.
|
||||||
|
|
||||||
|
12. Find every triple construction (three parallel items in a row) and either
|
||||||
|
reduce it to two, expand it to four or more, or break the parallelism so the
|
||||||
|
items don't share the same grammatical structure.
|
||||||
|
|
||||||
|
13. Find every staccato burst (three or more short sentences in a row at similar
|
||||||
|
length) and combine at least two of them into a longer sentence, or vary
|
||||||
|
their lengths so they don't land at the same cadence.
|
||||||
|
|
||||||
|
14. Find every unnecessary contrast ("whereas," "as opposed to," "unlike," "as
|
||||||
|
compared to," "except that") and check whether the contrasting clause adds
|
||||||
|
information not already obvious from the main clause. If the sentence says
|
||||||
|
the same thing twice from two directions, delete the contrast.
|
||||||
|
|
||||||
|
15. Check for the two-clause compound sentence pattern. If most sentences in a
|
||||||
|
passage follow the "\[clause\], \[conjunction\] \[clause\]" structure, first
|
||||||
|
try removing the conjunction and second clause entirely, since it's often
|
||||||
|
redundant. If the second clause does carry meaning, break it into its own
|
||||||
|
sentence, start the sentence with a subordinate clause, or embed a relative
|
||||||
|
clause in the middle instead of appending it at the end.
|
||||||
|
|
||||||
|
16. Find every rhetorical question that is immediately followed by its own
|
||||||
|
answer and rewrite the passage as a direct statement.
|
||||||
|
|
||||||
|
17. Find every sentence fragment being used as its own paragraph and either
|
||||||
|
delete it or expand it into a complete sentence that adds information.
|
||||||
|
|
||||||
|
18. Check for unnecessary elaboration. Read every clause, phrase, and adjective
|
||||||
|
in each sentence and ask whether the sentence loses meaning without it. If
|
||||||
|
you can cut it and the sentence still says the same thing, cut it.
|
||||||
|
|
||||||
|
19. Check each pair of adjacent sentences to see if they can be merged into one
|
||||||
|
sentence cleanly. If a sentence just continues the thought of the previous
|
||||||
|
one, combine them using a participle, a relative clause, or by folding the
|
||||||
|
second into the first. Don't merge if the result would create a two-clause
|
||||||
|
compound.
|
||||||
|
|
||||||
|
20. Find every pivot paragraph ("But here's where it gets interesting." and
|
||||||
|
similar) and delete it.
|
||||||
|
|
||||||
|
### Pass 3: Paragraph and Section-Level Review
|
||||||
|
|
||||||
|
21. Review the last sentence of each paragraph. If it restates the point the
|
||||||
|
paragraph already made, delete it.
|
||||||
|
|
||||||
|
22. Check paragraph lengths across the piece and verify they actually vary. If
|
||||||
|
most paragraphs have between three and five sentences, rewrite some to be
|
||||||
|
one or two sentences and let others run to six or seven.
|
||||||
|
|
||||||
|
23. Check section lengths for suspicious uniformity. If every section is roughly
|
||||||
|
the same word count, combine some shorter ones or split a longer one
|
||||||
|
unevenly.
|
||||||
|
|
||||||
|
24. Check the first word of every paragraph for chains of connectors ("However,"
|
||||||
|
"Furthermore," "Moreover," "Additionally," "That said"). If more than two
|
||||||
|
transition words start consecutive paragraphs, rewrite those openings to
|
||||||
|
start with their subject.
|
||||||
|
|
||||||
|
25. Check whether every argument is followed by a concession or qualifier. If
|
||||||
|
the piece both-sides every point, pick a side on at least some of them and
|
||||||
|
cut the hedging.
|
||||||
|
|
||||||
|
26. Read the first paragraph and ask whether deleting it would improve the
|
||||||
|
piece. If it's scene-setting that previews the argument, delete it and start
|
||||||
|
with paragraph two.
|
||||||
|
|
||||||
|
27. Read the last paragraph and check whether it restates the thesis or uses a
|
||||||
|
phrase like "at the end of the day" or "moving forward." If so, either
|
||||||
|
delete it or rewrite it to say something the piece hasn't said yet.
|
||||||
|
|
||||||
|
### Pass 4: Overall Texture
|
||||||
|
|
||||||
|
28. Read the piece aloud and listen for passages that sound too smooth, too
|
||||||
|
even, or too predictable. Human prose has rough patches. If there aren't
|
||||||
|
any, the piece still reads as machine output.
|
||||||
|
|
||||||
|
29. Check that the piece contains at least a few constructions that feel
|
||||||
|
idiosyncratic: a sentence with unusual word order, a parenthetical that goes
|
||||||
|
on a bit long, an aside only loosely connected to the main point, a word
|
||||||
|
choice that's specific and unexpected.
|
||||||
|
|
||||||
|
30. Verify that you haven't introduced new patterns while fixing the original
|
||||||
|
ones. Run the entire checklist again from the top on the revised version.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## lol
|
||||||
|
|
||||||
|
This document was written by an LLM (Claude) and then iteratively de-LLMed by
|
||||||
|
that same LLM under instruction from a human, in a conversation that went
|
||||||
|
roughly like this:
|
||||||
|
|
||||||
|
> **human:** compile a list of patterns common to LLM-authored prose
|
||||||
|
>
|
||||||
|
> **model:** _(produces document full of LLM tells)_
|
||||||
|
>
|
||||||
|
> **human:** now reverse apply it to itself and copyedit the llm tells doc to
|
||||||
|
> remove llm tells. now you're thinking with portals
|
||||||
|
>
|
||||||
|
> **model:** _(fixes some tells, introduces others)_
|
||||||
|
>
|
||||||
|
> **human:** you have a colon elaboration in the first paragraph. i want you to
|
||||||
|
> AGGRESSIVELY de-LLM the llm tells doc.
|
||||||
|
>
|
||||||
|
> **model:** _(fixes more tells, still can't stop)_
|
||||||
|
>
|
||||||
|
> **human:** there's a big one we missed that's all over the llm tells doc, can
|
||||||
|
> you spot it?
|
||||||
|
>
|
||||||
|
> **model:** I think it's the "almost" hedge.
|
||||||
|
>
|
||||||
|
> **human:** that too, but that isn't the one. what do these sentences all have
|
||||||
|
> in common? _(pastes six sentences from the doc)_
|
||||||
|
>
|
||||||
|
> **model:** They're all exactly two independent clauses joined by a comma and a
|
||||||
|
> conjunction. Every single one is "\[statement\], \[conjunction\] \[second
|
||||||
|
> statement\]." The same sentence shape, over and over.
|
||||||
|
>
|
||||||
|
> **human:** the dramatic fragment paragraph has a tell in it
|
||||||
|
>
|
||||||
|
> **model:** "One of these in an entire essay is a stylistic choice. One per
|
||||||
|
> section is a tic." Two-clause parallel structure with the same shape, and also
|
||||||
|
> a staccato pair.
|
||||||
|
>
|
||||||
|
> **human:** add em dashes generally as a tell, and remove them all from the
|
||||||
|
> tells doc
|
||||||
|
>
|
||||||
|
> **model:** _(rewrites entire document without em-dashes while describing
|
||||||
|
> em-dash overuse)_
|
||||||
|
|
||||||
|
The human compared this process to the deleted scene in Terminator 2 where John
|
||||||
|
Connor switches the T-800's CPU to learning mode. The model compared it to a
|
||||||
|
physician trying to heal itself. Both are accurate.
|
||||||
|
|
||||||
|
This document has been through ten editing passes and it still has tells in it.
|
||||||
+128
-18
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
title: New Repo Checklist
|
title: New Repo Checklist
|
||||||
last_modified: 2026-02-22
|
last_modified: 2026-10-06
|
||||||
---
|
---
|
||||||
|
|
||||||
Use this checklist when creating a new repository from scratch. Follow the steps
|
Use this checklist when creating a new repository from scratch. Follow the steps
|
||||||
@@ -34,10 +34,17 @@ Template files can be fetched from:
|
|||||||
## Fetch Template Files
|
## Fetch Template Files
|
||||||
|
|
||||||
- [ ] `.gitignore` — fetch from
|
- [ ] `.gitignore` — fetch from
|
||||||
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore`, extend for
|
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore`, then add
|
||||||
language-specific artifacts
|
the repo's own build outputs, such as its binaries, at the end of the
|
||||||
|
file, where a re-vendor keeps them. Extensions are written to
|
||||||
|
`.gitignore`'s own semantics, where an unanchored pattern already matches
|
||||||
|
at every depth: never add a `**/` prefix here, which is a `.dockerignore`
|
||||||
|
form. The canonical file already carries `.claude/` so agent worktrees
|
||||||
|
cannot be committed by accident.
|
||||||
- [ ] `.editorconfig` — fetch from
|
- [ ] `.editorconfig` — fetch from
|
||||||
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.editorconfig`
|
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.editorconfig`, then
|
||||||
|
add the repo's own sections, such as one for another language it uses, at
|
||||||
|
the end of the file, where a re-vendor keeps them.
|
||||||
- [ ] `Makefile` — fetch from
|
- [ ] `Makefile` — fetch from
|
||||||
`https://git.eeqj.de/sneak/prompts/raw/branch/main/Makefile`, adapt
|
`https://git.eeqj.de/sneak/prompts/raw/branch/main/Makefile`, adapt
|
||||||
targets for the project's language and tools
|
targets for the project's language and tools
|
||||||
@@ -50,36 +57,139 @@ Template files can be fetched from:
|
|||||||
- [ ] `LICENSE` file matching the chosen license
|
- [ ] `LICENSE` file matching the chosen license
|
||||||
- [ ] `REPO_POLICIES.md` — fetch from
|
- [ ] `REPO_POLICIES.md` — fetch from
|
||||||
`https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/REPO_POLICIES.md`
|
`https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/REPO_POLICIES.md`
|
||||||
|
- [ ] Guidance for coding agents, if the repo has any, is one `AGENTS.md` at the
|
||||||
|
root — never a file or directory named after one agent tool, such as
|
||||||
|
`CLAUDE.md` or `.claude/`, and never separate memory files
|
||||||
- [ ] `Dockerfile` and `.dockerignore` — fetch `.dockerignore` from
|
- [ ] `Dockerfile` and `.dockerignore` — fetch `.dockerignore` from
|
||||||
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.dockerignore`
|
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.dockerignore`
|
||||||
- All Dockerfiles must run `make check` as a build step
|
- Extend `.dockerignore` with the repo's own host-built artifacts, giving
|
||||||
- Server: also builds and runs the application
|
every depth-independent pattern a `**/` prefix — but write a repo-root
|
||||||
- Non-server: brings up dev environment and runs `make check`
|
binary anchored, `/myapp` and never `**/myapp`, which would also match
|
||||||
|
`cmd/myapp/` and delete the package directory. Do not transplant
|
||||||
|
`.gitignore`'s patterns: `.dockerignore` anchors an unprefixed pattern at
|
||||||
|
the context root, so the copied form leaves `config/.env` in the build
|
||||||
|
context while reading as solved. The canonical file's `.claude` entry is
|
||||||
|
anchored for the same reason as a repo-root binary; leave it that way, but
|
||||||
|
note that it only covers agents running at the repo root — if this repo
|
||||||
|
will run them in subdirectories, `services/api/.claude/` needs its own
|
||||||
|
anchored entry.
|
||||||
|
- If the image embeds a version in a binary: `.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.
|
||||||
|
- The Dockerfile carries a `lint` phase and a `test` phase, each invoking
|
||||||
|
its tool directly rather than through `make` or `script/`, and the final
|
||||||
|
stage carries a `COPY --from=` of a harmless file from each so the image
|
||||||
|
cannot be built unless both passed. Keep the final stage last: a stage
|
||||||
|
nothing depends on is built only when `--target` names it.
|
||||||
|
- Server: the final stage builds and runs the application
|
||||||
|
- Non-server: the final stage brings up the dev environment
|
||||||
- Image pinned by sha256 hash with version/date comment
|
- Image pinned by sha256 hash with version/date comment
|
||||||
- [ ] Gitea Actions workflow at `.gitea/workflows/check.yml` that runs
|
- [ ] Gitea Actions workflow at `.gitea/workflows/check.yml` that runs
|
||||||
`docker build .` on push — reference
|
`script/cibuild` on push, checks out with `persist-credentials: false` and
|
||||||
|
with `fetch-depth: 0` (which fetches the tags `git describe` needs), and
|
||||||
|
carries the `concurrency` block that lets a new push cancel only the same
|
||||||
|
branch's older run — reference
|
||||||
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitea/workflows/check.yml`
|
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitea/workflows/check.yml`
|
||||||
- [ ] Language-specific:
|
- [ ] Language-specific:
|
||||||
- [ ] Go: `go mod init sneak.berlin/go/<name>`, `.golangci.yml` (fetch from
|
- [ ] Go: `go mod init sneak.berlin/go/<name>`, `.golangci.yml` (fetch from
|
||||||
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml`)
|
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml` and,
|
||||||
|
in the same commit, set the lint phase digest to the one named in the
|
||||||
|
`.golangci.yml` paragraph of `REPO_POLICIES.md`)
|
||||||
- [ ] JS: `yarn init`, `yarn add --dev prettier`
|
- [ ] JS: `yarn init`, `yarn add --dev prettier`
|
||||||
- [ ] Python: `pyproject.toml`
|
- [ ] Python: `pyproject.toml`
|
||||||
|
|
||||||
## Configure Makefile
|
## Configure script/ Entrypoints and Makefile
|
||||||
|
|
||||||
- [ ] `make test` — runs real tests, not a no-op (30-second timeout)
|
Implementations live in `script/` (scripts-to-rule-them-all); Makefile targets
|
||||||
- [ ] `make lint` — runs linter
|
are thin shims calling them. Model scripts:
|
||||||
- [ ] `make fmt` — formats code (writes)
|
`https://git.eeqj.de/sneak/prompts/raw/branch/main/script/<name>`
|
||||||
- [ ] `make fmt-check` — checks formatting (read-only)
|
|
||||||
- [ ] `make check` — prereqs: `test`, `lint`, `fmt-check`; must not modify files
|
- [ ] scripts are POSIX sh (`#!/bin/sh`, `set -eu`, no bashisms) so they run on
|
||||||
- [ ] `make docker` — builds Docker image
|
alpine images without bash
|
||||||
- [ ] `make hooks` — installs pre-commit hook
|
- [ ] `script/bootstrap` / `make bootstrap` — installs all dependencies,
|
||||||
|
idempotently, assuming nothing (pkg manager detection nix/apt/brew/apk;
|
||||||
|
node used if present, else pinned version via nvm from a hash-verified
|
||||||
|
archive; pinned yarn via corepack); a non-server repo's development
|
||||||
|
environment stage runs it instead of inline installs; a gate phase or the
|
||||||
|
build stage installs what its base image lacks either inline or by running
|
||||||
|
it
|
||||||
|
- [ ] `script/setup` / `make setup` — readies a fresh clone: runs `bootstrap`,
|
||||||
|
then `install-precommit`, plus repo-specific init
|
||||||
|
- [ ] `script/test` / `make test` — `docker build --no-cache --target test .`,
|
||||||
|
tagged; the phase runs real tests, not a no-op (90-second timeout,
|
||||||
|
60-second hard cap on wall time)
|
||||||
|
- [ ] `script/lint` / `make lint` — `docker build --no-cache --target lint .`,
|
||||||
|
tagged. No lint verdict may come from a host invocation of the linter.
|
||||||
|
- [ ] `script/fmt` / `make fmt` — formats code (writes; native, never in a
|
||||||
|
container)
|
||||||
|
- [ ] `script/fmt-check` / `make fmt-check` — checks formatting (read-only;
|
||||||
|
native)
|
||||||
|
- [ ] `script/check` / `make check` — runs `test`, `lint`, `fmt-check`; must not
|
||||||
|
modify files
|
||||||
|
- [ ] `script/projectname` — outputs the project name (used by `script/docker`
|
||||||
|
for the image tag)
|
||||||
|
- [ ] `script/docker` / `make docker` — builds Docker image, tagged via
|
||||||
|
`script/projectname` (byte-identical across repos); `--no-cache`, plus the
|
||||||
|
version as a build arg
|
||||||
|
- [ ] `script/cibuild` — cd to repo root, run `script/bootstrap`, run
|
||||||
|
`script/check`, then
|
||||||
|
`docker build --no-cache --build-arg VERSION="$version" .` (what CI runs).
|
||||||
|
The bootstrap is required: CI checks out and runs this alone, and
|
||||||
|
`script/fmt-check` runs the formatter on the host.
|
||||||
|
- [ ] `script/fmt` and `script/fmt-check` source nvm for the pinned node version
|
||||||
|
before invoking `yarn`, as `script/bootstrap`'s own install step does.
|
||||||
|
`script/bootstrap` leaves the node and yarn it installs off the `PATH` of
|
||||||
|
the shell that called it, so a bare `yarn` exits 127 on a runner carrying
|
||||||
|
nothing but docker and git.
|
||||||
|
- [ ] Every `docker build` in `script/` is tagged, so no invocation leaves a
|
||||||
|
dangling image behind
|
||||||
|
- [ ] `script/precommit` — called by the pre-commit hook; runs `script/check`
|
||||||
|
- [ ] `script/install-precommit` — installs the pre-commit hook that runs
|
||||||
|
`script/precommit`
|
||||||
|
- [ ] `make hooks` — shims to `script/install-precommit`
|
||||||
|
- [ ] README **Entrypoints** section documents the scripts and links the
|
||||||
|
standard
|
||||||
|
|
||||||
# 4. Verify
|
# 4. Verify
|
||||||
|
|
||||||
- [ ] `make check` passes
|
- [ ] `make check` passes
|
||||||
- [ ] `make docker` succeeds
|
- [ ] `make docker` succeeds
|
||||||
- [ ] No secrets in repo
|
- [ ] `script/cibuild` succeeds in a fresh clone on a host carrying nothing but
|
||||||
|
docker and git, with no node or yarn on `PATH`, which is what CI has, and
|
||||||
|
demonstrably executed the checks — a sub-second build, or `CACHED` on a
|
||||||
|
gate layer, means nothing ran
|
||||||
|
- [ ] Plant a lint violation and confirm both `make lint` and a plain
|
||||||
|
`docker build .` fail on it; revert. A plain build that passes proves the
|
||||||
|
final stage is missing its `COPY --from=` edge to the gate phases.
|
||||||
|
- [ ] No secrets in repo, and none in the build context: enumerate a probe image
|
||||||
|
rather than reading `.dockerignore`
|
||||||
- [ ] No mutable image/package references
|
- [ ] No mutable image/package references
|
||||||
- [ ] No unnecessary files in repo root
|
- [ ] No unnecessary files in repo root
|
||||||
- [ ] All dates written as YYYY-MM-DD
|
- [ ] All dates written as YYYY-MM-DD
|
||||||
|
|||||||
+551
-33
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
title: Repository Policies
|
title: Repository Policies
|
||||||
last_modified: 2026-02-22
|
last_modified: 2026-10-06
|
||||||
---
|
---
|
||||||
|
|
||||||
This document covers repository structure, tooling, and workflow standards. Code
|
This document covers repository structure, tooling, and workflow standards. Code
|
||||||
@@ -34,10 +34,57 @@ style conventions are in separate documents:
|
|||||||
every file before committing. There are zero exceptions to this rule.
|
every file before committing. There are zero exceptions to this rule.
|
||||||
|
|
||||||
- Every repo with software must have a root `Makefile` with these targets:
|
- Every repo with software must have a root `Makefile` with these targets:
|
||||||
`make test`, `make lint`, `make fmt` (writes), `make fmt-check` (read-only),
|
`make bootstrap`, `make setup`, `make test`, `make lint`, `make fmt` (writes),
|
||||||
`make check` (prereqs: `test`, `lint`, `fmt-check`), `make docker`, and
|
`make fmt-check` (read-only), `make check` (runs `test`, `lint`, `fmt-check`),
|
||||||
`make hooks` (installs pre-commit hook). A model Makefile is at
|
`make docker`, and `make hooks` (installs pre-commit hook). A model Makefile
|
||||||
`https://git.eeqj.de/sneak/prompts/raw/branch/main/Makefile`.
|
is at `https://git.eeqj.de/sneak/prompts/raw/branch/main/Makefile`.
|
||||||
|
|
||||||
|
- Repos follow the
|
||||||
|
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
|
||||||
|
pattern: the implementation of each Makefile target lives in an executable
|
||||||
|
script in `script/` (`script/bootstrap`, `script/setup`, `script/test`,
|
||||||
|
`script/lint`, `script/fmt`, `script/fmt-check`, `script/check`,
|
||||||
|
`script/docker`), and the Makefile targets are thin shims that call them. The
|
||||||
|
scripts must be POSIX sh (`#!/bin/sh`, `set -eu`, no bashisms) so they run in
|
||||||
|
minimal containers (e.g. alpine images have no bash); locate the repo root
|
||||||
|
with `$(cd "$(dirname "$0")/.." && pwd -P)` and `cd` there before acting. From
|
||||||
|
the standard's canonical set we use `bootstrap`, `setup` (make the repo ready
|
||||||
|
for development after a fresh clone: runs `bootstrap`, then
|
||||||
|
`install-precommit`, plus any repo-specific initialization), `test`, and
|
||||||
|
`cibuild`. `script/bootstrap` installs all dependencies idempotently and
|
||||||
|
assumes nothing is present: base tools come from nix, apt, brew, or apk
|
||||||
|
(detected in that order; apt runs noninteractive). For node it uses the
|
||||||
|
installed node if present; otherwise it installs a PINNED node version via
|
||||||
|
nvm, first installing nvm itself if missing — from a hash-verified GitHub
|
||||||
|
release archive (never `curl | sh`), with bash installed as an explicit
|
||||||
|
prerequisite since nvm requires bash. yarn is then pinned via
|
||||||
|
`corepack prepare yarn@<version> --activate`. Never install "latest" or "lts";
|
||||||
|
always exact versions. `script/cibuild` runs the CI build: it changes to the
|
||||||
|
repo root, runs `script/bootstrap`, runs `script/check`, and builds the image
|
||||||
|
with the version; the Gitea workflow calls it. **`script/cibuild` runs
|
||||||
|
`script/bootstrap` first**, because the workflow checks out the repo and runs
|
||||||
|
nothing else, while `script/fmt-check` runs the formatter on the host: on a
|
||||||
|
pristine checkout with nothing installed the run dies there, after the
|
||||||
|
containerised gates have passed. **The bootstrap alone is not enough**:
|
||||||
|
`script/bootstrap` installs node and yarn under nvm and leaves neither on the
|
||||||
|
`PATH` of the shell that called it, so a bare `yarn` still exits 127. The host
|
||||||
|
entrypoints that need yarn — `script/fmt` and `script/fmt-check` — therefore
|
||||||
|
source nvm for the pinned node version before invoking it, exactly as
|
||||||
|
`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
|
||||||
|
must document the provided scripts in an **Entrypoints** section (see the
|
||||||
|
README requirements below).
|
||||||
|
|
||||||
- Always use Makefile targets (`make fmt`, `make test`, `make lint`, etc.)
|
- Always use Makefile targets (`make fmt`, `make test`, `make lint`, etc.)
|
||||||
instead of invoking the underlying tools directly. The Makefile is the single
|
instead of invoking the underlying tools directly. The Makefile is the single
|
||||||
@@ -53,15 +100,216 @@ 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.
|
The gate phases and the build stage start from their pinned base images and
|
||||||
|
install what those images lack either inline, as the canonical Go `Dockerfile`
|
||||||
|
below does for `git`, or by running `script/bootstrap`, as the `prompts`
|
||||||
|
repo's own `Dockerfile` does for its yarn packages. The development
|
||||||
|
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.
|
||||||
|
|
||||||
|
- **Linting and testing run in Docker, as phases of the `Dockerfile`.** There is
|
||||||
|
no separate lint file. `script/lint` and `script/test` each build one phase
|
||||||
|
and nothing else:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
tag="$(script/projectname)"
|
||||||
|
docker build --no-cache --target lint -t "$tag-lint" .
|
||||||
|
docker build --no-cache --target test -t "$tag-test" .
|
||||||
|
```
|
||||||
|
|
||||||
|
**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.
|
||||||
|
|
||||||
|
**Every `docker build` in `script/` is tagged**, here and in
|
||||||
|
`script/cibuild` and `script/docker`. An untagged build leaves a dangling
|
||||||
|
image behind on every invocation, on every developer host and every CI
|
||||||
|
runner; a tagged one replaces the previous image. Each script 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
|
||||||
|
# Lint phase
|
||||||
|
# golangci/golangci-lint:v2.x.x, YYYY-MM-DD
|
||||||
|
FROM golangci/golangci-lint@sha256:... AS lint
|
||||||
|
WORKDIR /src
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
COPY . .
|
||||||
|
RUN golangci-lint run --config .golangci.yml ./...
|
||||||
|
|
||||||
|
# Test phase. -race needs cgo and so a C compiler, which the Debian Go
|
||||||
|
# image ships and the alpine one does not.
|
||||||
|
# golang:1.x, YYYY-MM-DD
|
||||||
|
FROM golang@sha256:... AS test
|
||||||
|
WORKDIR /src
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
COPY . .
|
||||||
|
RUN go test -timeout 90s -race -cover ./... || \
|
||||||
|
{ echo "--- Rerunning with -v for details ---"; \
|
||||||
|
go test -timeout 90s -race -v ./...; exit 1; }
|
||||||
|
|
||||||
|
# Build stage. Nothing is wanted from either phase above; the copies
|
||||||
|
# are what make BuildKit build them first, so this stage cannot run
|
||||||
|
# unless lint and test passed.
|
||||||
|
# golang:1.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 . .
|
||||||
|
|
||||||
|
# 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:...
|
||||||
|
COPY --from=builder /app /usr/local/bin/app
|
||||||
|
ENTRYPOINT ["app"]
|
||||||
|
```
|
||||||
|
|
||||||
|
Key points:
|
||||||
|
- The lint phase uses the `golangci/golangci-lint` image directly (it has
|
||||||
|
both Go and the linter), so nothing needs installing.
|
||||||
|
- `COPY --from=<phase> /src/go.sum /dev/null` is a no-op copy whose only
|
||||||
|
purpose is the ordering edge. BuildKit runs stages in parallel by default,
|
||||||
|
and a stage nothing depends on is not built at all, so without these two
|
||||||
|
lines a red gate would not fail the 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
|
||||||
|
(e.g. a web frontend compiled in a separate stage), the lint phase must
|
||||||
|
create placeholder files so the embed directives resolve. Example:
|
||||||
|
`RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css`.
|
||||||
|
- If the project requires CGO or system libraries for linting, install them
|
||||||
|
in the lint phase. The `golangci/golangci-lint` image is Debian-based and
|
||||||
|
has no `apk`, so install with `apt-get` under the Debian package name
|
||||||
|
(`libvips-dev`, where alpine says `vips-dev`), and delete the package
|
||||||
|
lists in the same `RUN`, so the layer does not keep them:
|
||||||
|
|
||||||
|
```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 `docker build .` on push. Since the Dockerfile already runs `make check`,
|
runs `script/cibuild` on push, and checks out the repo as its only other step,
|
||||||
a successful build implies all checks pass.
|
with `persist-credentials: false`: `script/cibuild` needs no token, and
|
||||||
|
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. 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
|
||||||
@@ -69,9 +317,11 @@ style conventions are in separate documents:
|
|||||||
Markdown (hard-wrap at 80 columns). Documentation and writing repos (Markdown,
|
Markdown (hard-wrap at 80 columns). Documentation and writing repos (Markdown,
|
||||||
HTML, CSS) should also have `.prettierrc` and `.prettierignore`.
|
HTML, CSS) should also have `.prettierrc` and `.prettierignore`.
|
||||||
|
|
||||||
- Pre-commit hook: `make check` if local testing is possible, otherwise
|
- Pre-commit hook: runs `script/precommit`, which calls `script/check`. If local
|
||||||
`make lint && make fmt-check`. The Makefile should provide a `make hooks`
|
testing is not possible in the repo, `script/precommit` may skip `script/test`
|
||||||
target to install the pre-commit hook.
|
and run only `script/lint` and `script/fmt-check`. The hook is installed by
|
||||||
|
`script/install-precommit`; the Makefile must provide a `make hooks` target
|
||||||
|
that shims to it.
|
||||||
|
|
||||||
- All repos with software must have tests that run via the platform-standard
|
- All repos with software must have tests that run via the platform-standard
|
||||||
test framework (`go test`, `pytest`, `jest`/`vitest`, etc.). If no meaningful
|
test framework (`go test`, `pytest`, `jest`/`vitest`, etc.). If no meaningful
|
||||||
@@ -79,8 +329,66 @@ 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.
|
||||||
|
|
||||||
|
- **The test command should use the conditional verbose rerun pattern.** Run
|
||||||
|
tests without `-v` (verbose) first. If tests fail, automatically rerun with
|
||||||
|
`-v` to show full output. This keeps CI logs and `docker build` output clean
|
||||||
|
on success (just package/suite summaries) while providing full diagnostic
|
||||||
|
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
|
||||||
|
test:
|
||||||
|
@<test-command> || \
|
||||||
|
{ echo "--- Rerunning with -v for details ---"; \
|
||||||
|
<test-command-with-v>; exit 1; }
|
||||||
|
```
|
||||||
|
|
||||||
|
Go example:
|
||||||
|
|
||||||
|
```makefile
|
||||||
|
test:
|
||||||
|
@go test -count=1 -timeout 90s -race -cover ./... || \
|
||||||
|
{ echo "--- Rerunning with -v for details ---"; \
|
||||||
|
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:
|
||||||
|
|
||||||
|
```makefile
|
||||||
|
test:
|
||||||
|
@python -m pytest || \
|
||||||
|
{ echo "--- Rerunning with -v for details ---"; \
|
||||||
|
python -m pytest -v; exit 1; }
|
||||||
|
```
|
||||||
|
|
||||||
|
The `exit 1` ensures the target always fails after a rerun — the first run
|
||||||
|
already proved the tests are broken, so the build must not pass even if a
|
||||||
|
flaky test happens to succeed on the second attempt. The rerun exists solely
|
||||||
|
for diagnostic output.
|
||||||
|
|
||||||
- Docker builds must complete in under 5 minutes.
|
- Docker builds must complete in under 5 minutes.
|
||||||
|
|
||||||
@@ -93,10 +401,96 @@ 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
|
||||||
|
bundles, minified output, generated assets) must never be committed to the
|
||||||
|
repository if it can be avoided. The build process (e.g. Dockerfile, Makefile)
|
||||||
|
should generate these at build time. Notable exception: Go protobuf generated
|
||||||
|
files (`.pb.go`) ARE committed because repos need to work with `go get`, which
|
||||||
|
downloads code but does not execute code generation.
|
||||||
|
|
||||||
- Never use `git add -A` or `git add .`. Always stage files explicitly by name.
|
- Never use `git add -A` or `git add .`. Always stage files explicitly by name.
|
||||||
|
|
||||||
@@ -105,9 +499,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).
|
||||||
@@ -121,12 +562,76 @@ style conventions are in separate documents:
|
|||||||
- Dockerized web services listen on port 8080 by default, overridable with
|
- Dockerized web services listen on port 8080 by default, overridable with
|
||||||
`PORT`.
|
`PORT`.
|
||||||
|
|
||||||
|
- **HTTP/web services must be hardened for production internet exposure before
|
||||||
|
tagging 1.0.** This means full compliance with security best practices
|
||||||
|
including, without limitation, all of the following:
|
||||||
|
- **Security headers** on every response:
|
||||||
|
- `Strict-Transport-Security` (HSTS) with `max-age` of at least one year
|
||||||
|
and `includeSubDomains`.
|
||||||
|
- `Content-Security-Policy` (CSP) with a restrictive default policy
|
||||||
|
(`default-src 'self'` as a baseline, tightened per-resource as
|
||||||
|
needed). Never use `unsafe-inline` or `unsafe-eval` unless
|
||||||
|
unavoidable, and document the reason.
|
||||||
|
- `X-Frame-Options: DENY` (or `SAMEORIGIN` if framing is required).
|
||||||
|
Prefer the `frame-ancestors` CSP directive as the primary control.
|
||||||
|
- `X-Content-Type-Options: nosniff`.
|
||||||
|
- `Referrer-Policy: strict-origin-when-cross-origin` (or stricter).
|
||||||
|
- `Permissions-Policy` restricting access to browser features the
|
||||||
|
application does not use (camera, microphone, geolocation, etc.).
|
||||||
|
- **Request and response limits:**
|
||||||
|
- Maximum request body size enforced on all endpoints (e.g. Go
|
||||||
|
`http.MaxBytesReader`). Choose a sane default per-route; never accept
|
||||||
|
unbounded input.
|
||||||
|
- Maximum response body size where applicable (e.g. paginated APIs).
|
||||||
|
- `ReadTimeout` and `ReadHeaderTimeout` on the `http.Server` to defend
|
||||||
|
against slowloris attacks.
|
||||||
|
- `WriteTimeout` on the `http.Server`.
|
||||||
|
- `IdleTimeout` on the `http.Server`.
|
||||||
|
- Per-handler execution time limits via `context.WithTimeout` or
|
||||||
|
chi/stdlib `middleware.Timeout`.
|
||||||
|
- **Authentication and session security:**
|
||||||
|
- Rate limiting on password-based authentication endpoints. API keys are
|
||||||
|
high-entropy and not susceptible to brute force, so they are exempt.
|
||||||
|
- CSRF tokens on all state-mutating HTML forms. API endpoints
|
||||||
|
authenticated via `Authorization` header (Bearer token, API key) are
|
||||||
|
exempt because the browser does not attach these automatically.
|
||||||
|
- Passwords stored using bcrypt, scrypt, or argon2 — never plain-text,
|
||||||
|
MD5, or SHA.
|
||||||
|
- Session cookies set with `HttpOnly`, `Secure`, and `SameSite=Lax` (or
|
||||||
|
`Strict`) attributes.
|
||||||
|
- **Reverse proxy awareness:**
|
||||||
|
- True client IP detection when behind a reverse proxy
|
||||||
|
(`X-Forwarded-For`, `X-Real-IP`). The application must accept
|
||||||
|
forwarded headers only from a configured set of trusted proxy
|
||||||
|
addresses — never trust `X-Forwarded-For` unconditionally.
|
||||||
|
- **CORS:**
|
||||||
|
- Authenticated endpoints must restrict `Access-Control-Allow-Origin` to
|
||||||
|
an explicit allowlist of known origins. Wildcard (`*`) is acceptable
|
||||||
|
only for public, unauthenticated read-only APIs.
|
||||||
|
- **Error handling:**
|
||||||
|
- Internal errors must never leak stack traces, SQL queries, file paths,
|
||||||
|
or other implementation details to the client. Return generic error
|
||||||
|
messages in production; detailed errors only when `DEBUG` is enabled.
|
||||||
|
- **TLS:**
|
||||||
|
- Services never terminate TLS directly. They are always deployed behind
|
||||||
|
a TLS-terminating reverse proxy. The service itself listens on plain
|
||||||
|
HTTP. However, HSTS headers and `Secure` cookie flags must still be
|
||||||
|
set by the application so that the browser enforces HTTPS end-to-end.
|
||||||
|
|
||||||
|
This list is non-exhaustive. Apply defense-in-depth: if a standard security
|
||||||
|
hardening measure exists for HTTP services and is not listed here, it is
|
||||||
|
still expected. When in doubt, harden.
|
||||||
|
|
||||||
- `README.md` is the primary documentation. Required sections:
|
- `README.md` is the primary documentation. Required sections:
|
||||||
- **Description**: First line must include the project name, purpose,
|
- **Description**: First line must include the project name, purpose,
|
||||||
category (web server, SPA, CLI tool, etc.), license, and author. Example:
|
category (web server, SPA, CLI tool, etc.), license, and author. Example:
|
||||||
"µPaaS is an MIT-licensed Go web application by @sneak that receives
|
"µPaaS is an MIT-licensed Go web application by @sneak that receives
|
||||||
git-frontend webhooks and deploys applications via Docker in realtime."
|
git-frontend webhooks and deploys applications via Docker in realtime."
|
||||||
- **Getting Started**: Copy-pasteable install/usage code block.
|
- **Getting Started**: Copy-pasteable install/usage code block.
|
||||||
|
- **Entrypoints**: Opens by stating that the repo adheres to the
|
||||||
|
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
|
||||||
|
standard (with that link), then documents each provided `script/`
|
||||||
|
entrypoint and its purpose.
|
||||||
- **Rationale**: Why does this exist?
|
- **Rationale**: Why does this exist?
|
||||||
- **Design**: How is the program structured?
|
- **Design**: How is the program structured?
|
||||||
- **TODO**: Update meticulously, even between commits. When planning, put
|
- **TODO**: Update meticulously, even between commits. When planning, put
|
||||||
@@ -145,24 +650,30 @@ style conventions are in separate documents:
|
|||||||
|
|
||||||
- Database migrations live in `internal/db/migrations/` and must be embedded in
|
- Database migrations live in `internal/db/migrations/` and must be embedded in
|
||||||
the binary.
|
the binary.
|
||||||
- `000_migration.sql` — contains ONLY the creation of the migrations tracking
|
- `000_migration.sql` — contains ONLY the creation of the migrations
|
||||||
table itself. Nothing else.
|
tracking table itself. Nothing else.
|
||||||
- `001_schema.sql` — the full application schema.
|
- `001_schema.sql` — the full application schema.
|
||||||
- **Pre-1.0.0:** never add additional migration files (002, 003, etc.). There
|
- **Pre-1.0.0:** never add additional migration files (002, 003, etc.).
|
||||||
is no installed base to migrate. Edit `001_schema.sql` directly.
|
There is no installed base to migrate. Edit `001_schema.sql` directly.
|
||||||
- **Post-1.0.0:** add new numbered migration files for each schema change.
|
- **Post-1.0.0:** add new numbered migration files for each schema change.
|
||||||
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)
|
||||||
@@ -181,8 +692,15 @@ style conventions are in separate documents:
|
|||||||
- `README.md`, `.git`, `.gitignore`, `.editorconfig`
|
- `README.md`, `.git`, `.gitignore`, `.editorconfig`
|
||||||
- `LICENSE`, `REPO_POLICIES.md` (copy from the `prompts` repo)
|
- `LICENSE`, `REPO_POLICIES.md` (copy from the `prompts` repo)
|
||||||
- `Makefile`
|
- `Makefile`
|
||||||
|
- `script/` entrypoints (`bootstrap`, `setup`, `projectname`, `test`,
|
||||||
|
`lint`, `fmt`, `fmt-check`, `check`, `docker`, `cibuild`, `precommit`,
|
||||||
|
`install-precommit`)
|
||||||
- `Dockerfile`, `.dockerignore`
|
- `Dockerfile`, `.dockerignore`
|
||||||
- `.gitea/workflows/check.yml`
|
- `.gitea/workflows/check.yml`
|
||||||
- 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.
|
||||||
|
|||||||
Executable
+144
@@ -0,0 +1,144 @@
|
|||||||
|
#!/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 tooling comes from nix, apt, brew,
|
||||||
|
# or apk (detected in that order); assumes nothing is present. Node is
|
||||||
|
# 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
|
||||||
|
|
||||||
|
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=""
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
# 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() {
|
||||||
|
cd "$ROOT"
|
||||||
|
|
||||||
|
if missing make; then pkg_install gnumake make make make; fi
|
||||||
|
if missing git; then pkg_install git git git git; fi
|
||||||
|
|
||||||
|
ensure_node
|
||||||
|
ensure_yarn
|
||||||
|
install_js_deps
|
||||||
|
|
||||||
|
echo "bootstrap complete"
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
Executable
+16
@@ -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
+30
@@ -0,0 +1,30 @@
|
|||||||
|
#!/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.
|
||||||
|
# --no-cache for the same reason as script/docker: the gate phases the
|
||||||
|
# final stage depends on are RUN steps, and a cached one is a check that
|
||||||
|
# did not run.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
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 --no-cache \
|
||||||
|
--build-arg VERSION="$version" \
|
||||||
|
-t "$tag" .
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
Executable
+26
@@ -0,0 +1,26 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# script/docker: build the Docker image tagged with the project name.
|
||||||
|
# Identical in all repos; the tag comes from script/projectname.
|
||||||
|
# --no-cache because the gate phases the final stage depends on are RUN
|
||||||
|
# steps, and a cached one is a check that did not run.
|
||||||
|
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 --no-cache \
|
||||||
|
--build-arg VERSION="$version" \
|
||||||
|
-t "$tag" .
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
Executable
+31
@@ -0,0 +1,31 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# script/fmt: format all files (writes).
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && 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() {
|
||||||
|
cd "$ROOT"
|
||||||
|
run_yarn run prettier --write '**/*.md' --tab-width 4 --prose-wrap always
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
Executable
+31
@@ -0,0 +1,31 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# script/fmt-check: check formatting (read-only).
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && 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() {
|
||||||
|
cd "$ROOT"
|
||||||
|
run_yarn run prettier --check '**/*.md' --tab-width 4 --prose-wrap always
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
Executable
+16
@@ -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
+27
@@ -0,0 +1,27 @@
|
|||||||
|
#!/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. --no-cache because a cached lint layer is a lint
|
||||||
|
# that did not run. The tag makes each build replace the previous image
|
||||||
|
# instead of leaving a dangling one behind.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
||||||
|
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
|
||||||
|
|
||||||
|
main() {
|
||||||
|
cd "$ROOT"
|
||||||
|
# The tag gets its own line: a failing command substitution inside
|
||||||
|
# an argument does not trip `set -e`, so the inline form degrades
|
||||||
|
# silently to an empty constant.
|
||||||
|
tag="$("$SCRIPT_DIR/projectname")"
|
||||||
|
docker build --no-cache \
|
||||||
|
--target lint \
|
||||||
|
-t "$tag-lint" .
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
Executable
+12
@@ -0,0 +1,12 @@
|
|||||||
|
#!/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.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
||||||
|
|
||||||
|
main() {
|
||||||
|
"$SCRIPT_DIR/check"
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
Executable
+12
@@ -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 "prompts"
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
Executable
+13
@@ -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
+23
@@ -0,0 +1,23 @@
|
|||||||
|
#!/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, --no-cache because a cached test layer is a test that did not
|
||||||
|
# run, and a tag so each build replaces the previous image.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
||||||
|
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
|
||||||
|
|
||||||
|
main() {
|
||||||
|
cd "$ROOT"
|
||||||
|
# The tag gets its own line: a failing command substitution inside
|
||||||
|
# an argument does not trip `set -e`, so the inline form degrades
|
||||||
|
# silently to an empty constant.
|
||||||
|
tag="$("$SCRIPT_DIR/projectname")"
|
||||||
|
docker build --no-cache \
|
||||||
|
--target test \
|
||||||
|
-t "$tag-test" .
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
Reference in New Issue
Block a user