Re-vendor the shared files from sneak/prompts at dd4027b (closes #504)
check / check (push) Failing after 5s

The shared workflow, lint config, prettier settings and policies are the copies at `sneak/prompts` commit `dd4027b`. `.gitignore`, `.editorconfig` and `.dockerignore` are the shared copy followed by this repository's own entries. Linting is the image build's lint phase on golangci-lint v2.14.0, and tests run in their own test phase. Every scripted `docker build` passes `--no-cache`, so the CI fingerprint step and the superseded-run script are gone. The binary is built with `-trimpath -s -w`, and a build that has `.git` but no version fails. The development run keeps its databases outside the checkout.

Deviation: `.dockerignore` also leaves out SQLite databases at any depth.

Model: opus-5-5
This commit was merged in pull request #505.
This commit is contained in:
2026-10-06 06:05:42 +02:00
parent 46fe7baed0
commit fa6a9ed4dc
31 changed files with 883 additions and 1310 deletions
+121 -145
View File
@@ -17,14 +17,14 @@ before deploying one.
### Prerequisites
- Go 1.26.1+ (the version in `go.mod`)
- Docker (for `make lint`, `make fmt` and `make css`, and so for `make check`,
for the browser test in `make test-browser`, for the CI gate, and for
containerized deployment)
- Docker (for `make test`, `make lint`, `make fmt` and `make css`, and so for
`make check`, for the browser test in `make test-browser`, for the CI gate,
and for containerized deployment)
golangci-lint is not a prerequisite and must not be installed on the host:
`script/bootstrap` does not install it, and `make lint` runs the digest-pinned
linter image via `Dockerfile.lint`. The same holds for tailwindcss (see
[Stylesheet](#stylesheet)). ESLint, prettier, node and yarn are not
linter image in the Dockerfile's `lint` phase. The same holds for tailwindcss
(see [Stylesheet](#stylesheet)). ESLint, prettier, node and yarn are not
prerequisites either, and `make lint` and `make fmt` never use a host copy of
them (see [Linting](#linting)).
@@ -43,8 +43,9 @@ make check
# Run the server from the clone. DATA_DIR defaults to
# /var/lib/webhooker in every environment, so set it (in .env or the
# shell) to a writable directory.
DATA_DIR=./data make dev
# shell) to a writable directory outside the clone: the databases hold
# the session key.
DATA_DIR=../webhooker-data make dev
# Build Docker image
make docker
@@ -55,11 +56,11 @@ make docker
```bash
make bootstrap # Install all dependencies (idempotent)
make setup # Bootstrap + install git pre-commit hook
make assets # Extract Alpine.js from 3p/ (test, check, build, dev run it)
make assets # Extract Alpine.js from 3p/ (build and dev run it)
make fmt # Format Go (gofmt + goimports) and Markdown (prettier, in Docker)
make fmt-check # Fail if gofmt or prettier would change anything (writes nothing)
make lint # Run golangci-lint and ESLint in Docker
make test # Run tests with race detection
make test # Run tests with race detection, in Docker
make test-browser # Run the browser test in Docker (Dockerfile.browser)
make check # test + lint + fmt-check + css-check (CI gate)
make build # Build binary to bin/webhooker (version-stamped)
@@ -1095,7 +1096,9 @@ field), in the UI footer, and in the startup log line (`msg=starting`,
The value is stamped in at build time by the linker; it is not read from a file
at runtime, so it identifies the build itself.
`script/version` produces the value and both build paths use it:
`script/version` produces the value for `make build`, and the image build runs
`make build` too; `script/docker` and `script/cibuild` run the same
`git describe --tags --always --dirty` on the host. All of them report:
| Build | What it reports |
| ----------------------- | --------------------------------------------------- |
@@ -1110,18 +1113,19 @@ carries, so any `docker build .` of a clone, with no build arguments, stamps the
commit it was built from; a shallow clone of one branch has no tags and stamps
the short SHA. `.dockerignore` must therefore leave out neither `.git` nor any
tracked file, which git in the build would see as deleted, marking the version
`-dirty`. It does leave `.git/config`, which can hold a remote URL carrying a
credential and which `git describe` does not need, out of a directory context. A
context sent as a tar is not filtered by `.dockerignore`, so it carries
`.git/config` unless its sender leaves it out; for upaas, that is
`-dirty`. It does leave out every git `config` (`**/.git/config`,
`**/.git/modules/**/config`), which can hold a remote URL carrying a credential
and which `git describe` does not need, from a directory context. A context sent
as a tar is not filtered by `.dockerignore`, so it carries `.git/config` unless
its sender leaves it out; for upaas, that is
https://git.eeqj.de/sneak/upaas/issues/274. git in the build reads the checkout
whoever owns its files, since a context sent as a tar archive keeps the sender's
owners and git otherwise refuses a checkout owned by another user. A `VERSION`
build arg (`--build-arg VERSION=...`) takes precedence; `script/docker` (and so
`make docker`) passes the one `script/version` resolves on the host. The image
build fails if its context carries `.git` and the version still comes out
`unknown`, which means git is missing from the build or could not read the
checkout.
`make docker`) and `script/cibuild` pass the one they resolve on the host, or
`unknown` where git gives none. The image build fails if its context carries
`.git` and the version still comes out empty, `dev` or `unknown`, which means
git is missing from the build or could not read the checkout.
`unknown` is what a source tarball, or a `docker build` with no `.git` in its
context and no `VERSION` build arg, reports. A build that reports `unknown` is a
@@ -1135,7 +1139,9 @@ to a commit.
Nothing that varies between two builds of the same commit is stamped — no
timestamp, no hostname, no builder identity — so two builds of one commit still
produce a byte-identical binary.
produce a byte-identical binary. `make build` passes `-trimpath`, so the
directory it builds in is not recorded either, and `-s -w`, which leave out the
symbol table and debug information.
### Backups contain secrets
@@ -1213,11 +1219,14 @@ commands with no script behind them, though `build`, `run` and `dev` first run
`script/assets`, and `build` and `version` both take their value from
`script/version`.
`script/test`, `make build` and `make dev` each run `script/assets` first, which
writes the ignored `static/js/alpine.min.js` (see
[Third-party browser assets](#third-party-browser-assets)), so `make test`,
`make check` and the pre-commit hook work on a fresh clone without a separate
step.
`make build` and `make dev` each run `script/assets` first, which writes the
uncommitted `static/js/alpine.min.js` (see
[Third-party browser assets](#third-party-browser-assets)), so they work on a
fresh clone without a separate step. The Docker stages that compile the code run
it themselves.
Every `docker build` in `script/` passes `--no-cache`: a check served from the
build cache is a check that did not run.
We provide:
@@ -1227,10 +1236,12 @@ We provide:
- `script/projectname` — output the project name ("webhooker")
- `script/assets` — extract Alpine.js from its tarball in `3p/` (see
[Third-party browser assets](#third-party-browser-assets))
- `script/test` — run the test suite
- `script/test` — run the test suite: builds the Dockerfile's `test` phase,
tagged `webhooker-test`
- `script/test-browser` — run the browser test in Docker (see
[Third-party browser assets](#third-party-browser-assets))
- `script/lint` — run golangci-lint and ESLint in Docker (see Linting below)
- `script/lint` — run the `gofmt` check, golangci-lint and ESLint: builds the
Dockerfile's `lint` phase, tagged `webhooker-lint` (see Linting below)
- `script/fmt` — format the Go code and, in Docker, the Markdown (writes)
- `script/fmt-check` — check formatting (read-only)
- `script/css` — regenerate `static/css/tailwind.css` in Docker (writes; see
@@ -1241,11 +1252,11 @@ We provide:
- `script/version` — output the version to stamp into the binary (see
[Version stamping](#version-stamping))
- `script/docker` — build the Docker image tagged via `script/projectname`,
passing `script/version`'s output in as the `VERSION` build arg
- `script/cibuild` — CI entrypoint: `docker build .` (the Dockerfile runs the
checks, so a green build implies a green repo)
- `script/ci-mark-superseded` — CI helper: mark the commits whose run a newer
push cancelled (see [CI gate honesty](#ci-gate-honesty))
passing the version `git describe` gives on the host in as the `VERSION` build
arg
- `script/cibuild` — CI entrypoint: `script/bootstrap`, then `script/check`,
then the same image build as `script/docker`, whose gate phases run again (see
[CI gate honesty](#ci-gate-honesty))
- `script/precommit` — pre-commit checks (`go mod tidy` guard, then
`script/check`)
- `script/install-precommit` — install the git pre-commit hook that runs
@@ -1298,12 +1309,12 @@ apply. The directory is `3p/` rather than `vendor/` because Go treats a root
`script/assets` (`make assets`) extracts the browser build,
`package/dist/cdn.min.js`, from the tarball to `static/js/alpine.min.js`, where
`go:embed` picks it up. `script/test`, `make build` and `make dev` run it first,
and the Dockerfile builds through `make test` and `make build`, so nothing
downloads Alpine.js. The extracted file is not committed, and `.dockerignore`
keeps any host copy out of the build context. `static/static.go` names every
file it embeds, so a build that skips the extraction, such as a bare `go build`,
fails with an error naming `js/alpine.min.js`.
`go:embed` picks it up. `make build` and `make dev` run it first, and so do the
Dockerfile's lint, test and build stages, so nothing downloads Alpine.js. The
extracted file is not committed, and `.dockerignore` keeps any host copy out of
the build context. `static/static.go` names every file it embeds, so a build
that skips the extraction, such as a bare `go build`, fails with an error naming
`js/alpine.min.js`.
To move to a new version: download
`https://registry.npmjs.org/@alpinejs/csp/-/csp-<version>.tgz`, check it against
@@ -2969,8 +2980,6 @@ webhooker/
├── internal/
│ ├── banner/
│ │ └── banner.go # Ruled block for the one credential shown in the clear
│ ├── ciscript/
│ │ └── doc.go # Tests for the CI shell scripts in script/; no runtime code
│ ├── resetpw/
│ │ └── resetpw.go # `webhooker resetpw`: set an account's password, stopped deployments only
│ ├── config/
@@ -3075,8 +3084,7 @@ webhooker/
│ └── js/alpine.min.js # Alpine.js CSP build, extracted from 3p/ by make assets, not committed
├── templates/ # Go HTML templates (base, login, sources, etc.)
├── script/ # Scripts to Rule Them All entrypoints
├── Dockerfile # Stages: lint, stylesheet, JavaScript lint, Markdown, test+build, Alpine runtime
├── Dockerfile.lint # Lint-only image built by script/lint
├── Dockerfile # Stages: stylesheet, JavaScript lint, Markdown, lint, test, build, Alpine runtime
├── Dockerfile.browser # Browser test image built by script/test-browser
├── Makefile # 13 of 19 targets shim script/; 6 are inline
├── go.mod / go.sum
@@ -3084,6 +3092,7 @@ webhooker/
├── .yarnrc.yml # yarn settings: install into node_modules/
├── eslint.config.mjs # ESLint configuration for static/js/
├── .prettierrc # prettier settings for the Markdown
├── .prettierignore # Files prettier skips
└── .golangci.yml # golangci-lint configuration
```
@@ -3339,47 +3348,36 @@ Two operational consequences follow from bounding the sequence:
### Linting
golangci-lint never runs on the host. `script/lint` builds `Dockerfile.lint`,
which copies the repo into the digest-pinned golangci-lint image and lints as a
build step, so a successful build is a clean lint. A host binary would share one
cache and one lock with every other checkout on the machine, which has produced
both invented findings attributed to other worktrees and unearned passes.
golangci-lint never runs on the host. `script/lint` builds the Dockerfile's
`lint` phase, which copies the repo into the digest-pinned golangci-lint image
and lints as a build step, so a successful build is a clean lint. A host binary
would share one cache and one lock with every other checkout on the machine,
which has produced both invented findings attributed to other worktrees and
unearned passes.
Three properties are load-bearing:
Two properties are load-bearing:
- `script/lint` passes `--no-cache-filter=lint`. Without it an unchanged tree
replays the lint layer from cache and the build exits 0 in under a second
having linted nothing. The `deps` stage stays cacheable, so module downloads
are not repeated. Invalidation is scoped to the one stage; never prune the
shared build cache.
- `script/lint` does not trust that flag. Docker silently ignores
`--no-cache-filter` for a stage name that does not match, so a stage rename or
a one-character typo would restore the cached false green with no warning and
a fast exit 0. The script therefore tees the build output and treats a run as
a pass only if golangci-lint's own summary line (`N issues.` / `N issues:`)
appears in it: no summary, no lint, whatever the exit code says.
- Both lint steps use `RUN --network=none`. `golangci-lint config verify` is
documented as fetching its JSON schema over HTTPS, which would be an unpinned
remote dependency; the pinned image resolves the schema without network
access, and `--network=none` enforces that instead of trusting it. Verify is
worth keeping because `golangci-lint run` silently ignores config keys it does
not recognize, so a typo would disable a setting with no warning.
- `script/lint` passes `--no-cache`. Without it an unchanged tree replays the
lint layer from cache and the build exits 0 in under a second having linted
nothing. Never prune the shared build cache instead.
- Both golangci-lint steps use `RUN --network=none`.
`golangci-lint config verify` is documented as fetching its JSON schema over
HTTPS, which would be an unpinned remote dependency; the pinned image resolves
the schema without network access, and `--network=none` enforces that instead
of trusting it. Verify is worth keeping because `golangci-lint run` silently
ignores config keys it does not recognize, so a typo would disable a setting
with no warning.
ESLint never runs on the host either. It lints `static/js/` (not the extracted
Alpine.js) in the Dockerfile's `js-lint` stage, which `script/lint` builds after
`Dockerfile.lint` and the image build runs before the builder stage. Its version
is pinned in `package.json` and every package's hash in `yarn.lock`. The
`js-deps` stage before it installs ESLint with `yarn install --immutable`, which
fails rather than change `yarn.lock`. The yarn it runs is the one the
`packageManager` field in `package.json` pins by version and hash, which the
node image's own corepack fetches and checks. The stage stays cached until
`package.json`, `yarn.lock` or `.yarnrc.yml` changes, so only the lint step
re-runs and ESLint is not downloaded again. `eslint.config.mjs` turns on the
rules of the JavaScript styleguide `REPO_POLICIES.md` links to that a linter can
check: `no-var` and `prefer-const`. ESLint prints nothing on a pass, so
`script/lint` has no summary line to look for; it names the stage once for both
`--target` and `--no-cache-filter`, and `--target` fails on a name that matches
no stage.
Alpine.js) in the Dockerfile's `js-lint` stage. The `lint` phase copies a file
from it, so `make lint` and the image build both run ESLint. Its version is
pinned in `package.json` and every package's hash in `yarn.lock`. The `js-deps`
stage before it installs ESLint with `yarn install --immutable`, which fails
rather than change `yarn.lock`. The yarn it runs is the one the `packageManager`
field in `package.json` pins by version and hash, which the node image's own
corepack fetches and checks. `eslint.config.mjs` turns on the rules of the
JavaScript styleguide `REPO_POLICIES.md` links to that a linter can check:
`no-var` and `prefer-const`.
prettier formats the Markdown, and it never runs on the host either. It is
pinned in `package.json` and `yarn.lock` beside ESLint, installed by the same
@@ -3391,103 +3389,81 @@ on any Markdown file prettier would change.
### Docker
The Dockerfile uses a multi-stage build. Each stage is pinned by digest, and the
lint and builder stages are separate images so the linter's version is fixed
independently of the compiler's:
lint phase and the Go stages are separate images so the linter's version is
fixed independently of the compiler's:
1. **Lint stage** (`golangci/golangci-lint:v2.12.2`, Debian-based) — downloads
dependencies, copies the source, and runs the `gofmt` check, then
`script/assets` to extract Alpine.js from `3p/`, then
`golangci-lint config verify` and `golangci-lint run`, both with
`--network=none`.
2. **Stylesheet stages** (`debian:bookworm-slim`, with the Tailwind standalone
1. **Stylesheet stages** (`debian:bookworm-slim`, with the Tailwind standalone
CLI pinned by version and sha256, one binary per architecture) — generate
`static/css/tailwind.css` from `static/css/input.css` and the files its
`@source` lines name. `css-check` fails when the committed file differs from
the generated one, and `make css` writes the generated file out from
`css-output` (see [Stylesheet](#stylesheet)).
3. **JavaScript lint stages** (`node:24.21.0-alpine`, with the yarn
2. **JavaScript lint stages** (`node:24.21.0-alpine`, with the yarn
`package.json` pins, run through the image's corepack) — `js-deps` installs
ESLint and prettier from `yarn.lock` and `js-lint` runs ESLint over
`static/js/` (see [Linting](#linting)).
4. **Markdown stages** (on `js-deps`) — `markdown-check` runs prettier over the
3. **Markdown stages** (on `js-deps`) — `markdown-check` runs prettier over the
Markdown and fails on any file it would change, and `make fmt` writes the
formatted files out from `markdown-output`.
5. **Builder stage** (`golang:1.26.1-bookworm`) — depends on the lint,
`css-check`, `js-lint` and `markdown-check` stages passing (it copies a file
from each), runs `make test` and `make build` (both extract Alpine.js from
`3p/` first), and finally rebuilds the binary with `CGO_ENABLED=1` and static
linking so it runs on musl. Both builds go through `make build`, the relink
adding its `-extldflags` via `GO_LDFLAGS`, so neither can drop the `-X` that
stamps the version. The version is the `VERSION` build arg if one is given,
4. **Lint phase** (`lint`, `golangci/golangci-lint:v2.14.0`, Debian-based) —
downloads dependencies, copies the source, and runs the `gofmt` check, then
`script/assets` to extract Alpine.js from `3p/`, then
`golangci-lint config verify` and `golangci-lint run`, both with
`--network=none`, and depends on `js-lint` (it copies a file from it).
`make lint` builds this stage alone.
5. **Test phase** (`test`, `golang:1.26.1-bookworm`, whose C compiler `-race`
needs) — extracts Alpine.js, then runs `go test -race -cover` at most four
packages and eight tests at a time, with a 90-second timeout per package. On
a failure it runs only the failed tests again with `-v`, since verbose output
from the whole suite would pass the 2 MiB at which the Docker build cuts off
a step's log, and then fails. `make test` builds this stage alone.
6. **Builder stage** (`golang:1.26.1-bookworm`) — depends on the lint and test
phases and the `css-check` and `markdown-check` stages passing (it copies a
file from each), runs `make build` (which extracts Alpine.js from `3p/`
first), and then rebuilds the binary with `CGO_ENABLED=1` and static linking
so it runs on musl. Both builds go through `make build`, the relink adding
its `-extldflags` via `GO_LDFLAGS`, so neither can drop the `-X` that stamps
the version. The version is the `VERSION` build arg if one is given,
otherwise derived from the `.git` in the context, and the stage fails if a
context with `.git` would stamp `unknown` (see
context with `.git` would stamp an empty version, `dev` or `unknown` (see
[Version stamping](#version-stamping)).
6. **Runtime stage** (`alpine:3.21`) — copies the static binary and
7. **Runtime stage** (`alpine:3.21`) — copies the static binary and
`deploy/docker-entrypoint.sh`, creates the `/var/lib/webhooker` directory for
all SQLite databases, exposes port 8080, and includes a health check against
`/.well-known/healthcheck`. It sets no `USER`: the `ENTRYPOINT` script starts
as root, sets the data directory's owner and mode, and runs the app as the
non-root `webhooker` user (UID 1000) through `su-exec`.
The lint stage invokes `gofmt` and `golangci-lint` directly rather than
`make fmt-check` and `make lint`: it is already the pinned linter image, and
both targets build docker stages, which would need a docker daemon inside this
build.
The lint and test phases invoke `gofmt`, `golangci-lint` and `go test` directly
rather than `make fmt-check`, `make lint` and `make test`: those targets build
docker stages, which would need a docker daemon inside this build.
The lint and builder stages use Debian rather than Alpine because
The lint phase and the Go stages use Debian rather than Alpine because
`gorm.io/driver/sqlite` pulls in `mattn/go-sqlite3`, which needs CGO and does
not compile against musl. Only the final binary is statically linked, which is
what lets it run on the Alpine runtime image.
`script/cibuild` — `docker build .` — is the CI gate: the checks run inside the
image, so a build that succeeds is a repo that is formatted, linted, tested and
compiled, with a current stylesheet. `script/lint` also uses Docker
(`Dockerfile.lint` and the `js-lint` stage, see Linting above), so `make lint`
and `make check` run the same pinned linter versions the gate does; of the steps
`make check` runs, only `script/test` and the `gofmt` check in
`script/fmt-check` run on the host.
`script/cibuild` is the CI gate: it runs `script/bootstrap`, then
`script/check`, then builds the image, whose build runs the lint and test phases
and the stylesheet and Markdown checks again. A build that succeeds is a repo
that is formatted, linted, tested and compiled, with a current stylesheet.
`make check` runs the same stages the gate does; of its steps, only the `gofmt`
check in `script/fmt-check` runs on the host.
#### CI gate honesty
A layer cache lets `docker build .` exit 0 in seconds with the lint and test
stages replayed rather than executed, which would make a green check
meaningless. The `check` workflow therefore writes `.ci-fingerprint` into the
build context before building. Its value is the hash of the commit being
checked, so every commit, docs-only ones and a squash merge whose tree matches
an already-built branch included, gets a new fingerprint, invalidates the
`COPY . .` layer of every check stage, and really runs the `gofmt` check,
`golangci-lint`, the stylesheet check, ESLint, the Markdown check, `make test`,
and `make build`. A run that reports success ran them.
meaningless. Every `docker build` in `script/` therefore passes `--no-cache`, so
on every run the `gofmt` check, `golangci-lint`, ESLint, the stylesheet check,
the Markdown check, `go test` and `make build` really execute. A run that
reports success ran them. A bare `docker build .` carries no such guarantee.
The module download layer sits above `COPY . .` and stays cached.
A separate workflow step, run before the fingerprint is written, covers a second
way the gate lied: Gitea cancels an in-flight run when a newer commit lands on
the same branch and records that cancellation as a `failure` status, so a commit
nothing ever tested reads as a test result. Cancellation is unconditional
server-side for push events, so the superseding run calls
`script/ci-mark-superseded`, which rewrites that exact status to `failure` /
`Superseded by a newer commit; never tested`.
The state stays `failure` on purpose: Gitea's combined status folds `skipped`
into `success`, so marking a never-tested commit `skipped` made the status API
report green for it, indistinguishable from a commit that passed. Reading a
commit's status on this repo therefore goes:
- `success` / `Successful in ...` — the checks ran and passed.
- `failure` / `Failing after ...` — the checks ran and failed.
- `failure` / `Superseded by a newer commit; never tested` — the run was
cancelled, by a newer push or by hand, and nothing was verified about this
commit. Test the commit itself before concluding anything about it.
Genuine failures and successes are never touched, and no status is left
`pending`, which would block the commit indefinitely. The step derives its
context string from the workflow name, the job **id** and the event. That is
deliberately not byte-identical to Gitea's own rule, which uses the job's
display `name:` where the runner exports the id, so giving the job a `name:` —
or renaming the workflow — makes the derived context stop matching. The step
fails loudly when no status on the commit carries that context, so no rename can
silently disable the rewrite.
The `check` workflow is the shared one from `REPO_POLICIES.md`: it checks out
the repository and runs `script/cibuild`, nothing else. Gitea cancels an
in-flight run when a newer commit lands on the same branch and records that as
`failure` / `Has been cancelled`: nothing was verified about that commit, so
test the commit itself before concluding anything about it.
## TODO