WIP: next #34

Draft
clawbot wants to merge 6 commits from next into main
10 changed files with 297 additions and 48 deletions
Showing only changes of commit 3a218497b8 - Show all commits

View File

@@ -10,7 +10,10 @@
# context root. A host-built binary is the usual case, and it must be # context root. A host-built binary is the usual case, and it must be
# written anchored: `/myapp`, never `**/myapp`. The prefixed form also # written anchored: `/myapp`, never `**/myapp`. The prefixed form also
# matches `cmd/myapp/`, which deletes the package directory from the # matches `cmd/myapp/`, which deletes the package directory from the
# context. # context. In-repo agent scratch is the other case, for the same reason
# — with the caveat recorded at that entry: anchoring is exact only
# where agents run at the repo root, and a repo where they do not must
# add its own entries.
# #
# Matching is case-sensitive, so `**/*.key` does not match # Matching is case-sensitive, so `**/*.key` does not match
# `certs/SERVER.KEY`, which is reachable on the case-insensitive # `certs/SERVER.KEY`, which is reachable on the case-insensitive
@@ -31,9 +34,33 @@
# belong here because a host build otherwise drops them into the # belong here because a host build otherwise drops them into the
# context. # context.
# Repository metadata: exactly one, at the context root. # Repository metadata: exactly one, at the context root. Excluding it
# means `git describe` cannot run in any build stage, and it fails
# quietly there rather than erroring, so a version embedded that way
# comes out empty. Compute the version on the host and pass it in with
# `--build-arg VERSION=...`; see the version rule in REPO_POLICIES.md.
.git .git
# In-repo agent scratch: a directory holding a full additional checkout
# of the repo for each in-flight agent. Anchored because it occurs
# exactly once *where agents run at the repo root*, which is the
# convention this file assumes; the `**/` form would also match any
# nested directory of that name and delete it from the build.
#
# KNOWN GAP, and it is not hypothetical: the directory is created in the
# agent's working directory. If agents in this repo run in
# subdirectories — a monorepo with a per-service agent, say — then
# `services/api/.claude/` is NOT excluded by the line below and still
# reaches the build context and the image, which is the exposure this
# entry exists to close. A repo in that shape adds its own anchored
# entries (`/services/api/.claude`), or `**/.claude` after confirming no
# legitimately named nested directory would be caught.
#
# Not case-folded, unlike the secret patterns below: tooling creates
# this directory in exactly one spelling, so a folded pattern would add
# no coverage.
.claude
# Environment files. `*.env` covers both the bare `.env` name (`*` matches # Environment files. `*.env` covers both the bare `.env` name (`*` matches
# the empty string) and the `prod.env` convention. # the empty string) and the `prod.env` convention.
**/*.[eE][nN][vV] **/*.[eE][nN][vV]

6
.gitignore vendored
View File

@@ -11,6 +11,12 @@ 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/

26
TODO.md
View File

@@ -21,6 +21,32 @@ fmt-check, and commit.
# Completed Steps # Completed Steps
- 2026-08-09: Kept in-repo agent scratch out of the Docker build context and out
of version control. `.claude/` holds one worktree — an entire additional
checkout of the repo — per in-flight agent, and under `COPY . .` all of it was
reaching the image: another session's unreviewed, sometimes uncommitted work,
inflating the context by a multiple of the repo and invalidating `COPY` for
reasons unrelated to the repo's own content. The `.dockerignore` entry is
root-anchored, because the directory occurs exactly once where agents run at
the repo root and the `**/` form additionally deletes any nested directory of
that name — with the residual gap that follows from anchoring (a monorepo
running agents in subdirectories still ships `services/api/.claude/`) stated
in the canonical `.dockerignore`, the policy and the existing-repo checklist,
since consuming repos receive the files rather than the tracker; the
`.gitignore` entry is unanchored, because `.gitignore` patterns already match
at every depth, and each file is written to its own semantics rather than
derived from the other. Also closed the consequence that ships broken
silently: excluding `.git` means `git describe` cannot run in any build stage
and yields an empty version without erroring, so `script/docker` and
`script/cibuild` now compute the version on the host and pass
`--build-arg VERSION`, and `REPO_POLICIES.md` states where `VERSION` comes
from instead of leaving the reader to fill the gap with `git describe` inside
the build. The two Go documents that carry the `GOLDFLAGS` pattern were
corrected in the same pass, from `:=` to `?=`, since a `$(shell git describe)`
evaluated inside a build stage is exactly the empty version this closes.
Verified by enumerating a probe image before, after, and against the
`**/`-prefixed form, with a positive control and the `CHECK_EPOCH` cache
verification re-run under the changed build context.
- 2026-08-09: Closed the secret exposure in the canonical `.dockerignore`: a - 2026-08-09: Closed the secret exposure in the canonical `.dockerignore`: a
developer's local `.env`, `*.pem` or `*.key` was reaching the Docker build developer's local `.env`, `*.pem` or `*.key` was reaching the Docker build
context under `COPY . .`, invisible to every git-based check because context under `COPY . .`, invisible to every git-based check because

View File

@@ -49,7 +49,20 @@ last_modified: 2026-08-09
``` ```
```make ```make
VERSION := $(shell git describe --always --dirty) # ?= rather than := because this `$(shell git describe ...)` is only
# correct on the host. `.dockerignore` excludes `.git`, so evaluated
# inside a build stage it expands to the empty string without failing
# and the binary reports no version at all. The version is computed on
# the host by `script/docker` / `script/cibuild` and passed with
# `--build-arg VERSION=...`. If this repo's Dockerfile compiles by
# invoking make (`RUN make build`), `ARG VERSION` in that stage puts the
# value in the environment and `?=` defers to it. The canonical Go
# template in REPO_POLICIES.md instead runs `go build` directly with
# `-ldflags "... -X main.Version=${VERSION}"`, so there this Makefile is
# a host-only path — but it is still `?=`, because a repo that later
# moves the build behind make must not silently start shipping an empty
# version. See the git-describe rule in REPO_POLICIES.md.
VERSION ?= $(shell git describe --always --dirty)
BUILDARCH := $(shell uname -m) BUILDARCH := $(shell uname -m)
GOLDFLAGS += -X main.Version=$(VERSION) GOLDFLAGS += -X main.Version=$(VERSION)

View File

@@ -24,9 +24,14 @@ 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) — - [ ] `.gitignore` is comprehensive (OS, editor, agent scratch, language
fetch from `https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` artifacts, secrets) — fetch from
if missing `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: `.claude/` in
particular, unanchored, so agent worktrees cannot be committed by
accident. Do not give it a `**/` prefix — that is a `.dockerignore` form
and is wrong here.
- [ ] `.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`
- [ ] `Dockerfile` and `.dockerignore` exist (fetch `.dockerignore` from - [ ] `Dockerfile` and `.dockerignore` exist (fetch `.dockerignore` from
@@ -42,6 +47,26 @@ with your task.
`/myapp`, never `**/myapp`, which would also match `cmd/myapp/`. An `/myapp`, never `**/myapp`, which would also match `cmd/myapp/`. An
existing repo is where such a binary is likeliest to already be sitting in existing repo is where such a binary is likeliest to already be sitting in
the build context, invisible to git. the build context, invisible to git.
- [ ] `.dockerignore` excludes `.claude`, root-anchored and with 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. Confirm by enumerating the image, not by reading
the file — `.gitignore` hides these from `git status` too.
- [ ] **Do agents in this repo run anywhere other than the repo root?** The
scratch directory is created in the agent's working directory, so the
canonical anchored entry misses `services/api/.claude/` in a monorepo with
a per-service agent — it still reaches the build context and the image. An
existing repo is where such a layout already exists, so check it here
rather than assuming the canonical entry covers you: add anchored entries
for the subdirectories that have one (`/services/api/.claude`), or
`**/.claude` once you have confirmed no legitimately named nested
directory would be caught.
- [ ] If the repo embeds a version in a binary, that version is computed on the
host and passed with `--build-arg VERSION=...` by `script/docker` and
`script/cibuild`. No stage calls `git describe`: `.dockerignore` excludes
`.git`, so it yields an empty version without failing the build. A
tag-derived version additionally needs `fetch-depth: 0` on the CI checkout
step, which clones shallow and fetches no tags by default.
- [ ] Every depth-independent pattern in `.dockerignore` carries a `**/` prefix; - [ ] Every depth-independent pattern in `.dockerignore` carries a `**/` prefix;
only genuinely root-anchored entries such as `.git` are unprefixed, and only genuinely root-anchored entries such as `.git` are unprefixed, and
`.gitignore`'s patterns have not been transplanted unmodified. `.gitignore`'s patterns have not been transplanted unmodified.

View File

@@ -1,6 +1,6 @@
--- ---
title: Go HTTP Server Conventions title: Go HTTP Server Conventions
last_modified: 2026-02-22 last_modified: 2026-08-09
--- ---
This document defines the architectural patterns, design decisions, and This document defines the architectural patterns, design decisions, and
@@ -991,7 +991,14 @@ 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 := because this `$(shell git describe ...)` is only correct
# on the host: `.dockerignore` excludes `.git`, so evaluated inside a build
# stage it expands to the empty string without failing and the binary reports
# no version. The version is computed on the host by `script/docker` /
# `script/cibuild` and passed with `--build-arg VERSION=...`; where the build
# stage invokes make, `ARG VERSION` puts it in the environment and `?=` defers
# to it. See the git-describe rule in REPO_POLICIES.md.
VERSION ?= $(shell git describe --tags --always)
BUILDARCH := $(shell go env GOARCH) BUILDARCH := $(shell go env GOARCH)
build: build:

View File

@@ -35,7 +35,11 @@ Template files can be fetched from:
- [ ] `.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`, extend for
language-specific artifacts language-specific artifacts. 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`
- [ ] `Makefile` — fetch from - [ ] `Makefile` — fetch from
@@ -59,7 +63,16 @@ Template files can be fetched from:
`.gitignore`'s patterns: `.dockerignore` anchors an unprefixed pattern at `.gitignore`'s patterns: `.dockerignore` anchors an unprefixed pattern at
the context root, so the copied form leaves `config/.env` in the build the context root, so the copied form leaves `config/.env` in the build
context while reading as solved. See the `.dockerignore` rule in context while reading as solved. See the `.dockerignore` rule in
`REPO_POLICIES.md`. `REPO_POLICIES.md`. The canonical file's `.claude` entry is anchored for
the same reason as a repo-root binary; leave it that way, but note it only
covers agents running at the repo root — if this repo will run them in
subdirectories, `services/api/.claude/` is not excluded and needs its own
anchored entry.
- If the image embeds a version in a binary, the version is computed on the
host and passed with `--build-arg VERSION=...`. `ARG VERSION=dev` is
declared in the stage that compiles, and **no stage calls `git describe`**
`.dockerignore` excludes `.git`, so it yields an empty version without
failing the build.
- All Dockerfiles must run `make check` as a build step, and every stage - All Dockerfiles must run `make check` as a build step, and every stage
containing a check-running `RUN` must declare `ARG CHECK_EPOCH` with the containing a check-running `RUN` must declare `ARG CHECK_EPOCH` with the
`RUN [ -n "$CHECK_EPOCH" ] || exit 1` guard immediately below it — see the `RUN [ -n "$CHECK_EPOCH" ] || exit 1` guard immediately below it — see the
@@ -103,14 +116,30 @@ are thin shims calling them. Model scripts:
- [ ] `script/projectname` — outputs the project name (used by `script/docker` - [ ] `script/projectname` — outputs the project name (used by `script/docker`
for the image tag) for the image tag)
- [ ] `script/docker` / `make docker` — builds Docker image, tagged via - [ ] `script/docker` / `make docker` — builds Docker image, tagged via
`script/projectname` (byte-identical across repos); assigns `script/projectname` (byte-identical across repos); carries the same three
`epoch="$(date +%s%N)$$"` on its own line and passes version lines as `script/cibuild` below, and passes
`--build-arg CHECK_EPOCH="$epoch"` `--build-arg CHECK_EPOCH="$epoch"` and `--build-arg VERSION="$version"`
- [ ] `script/cibuild` — cd to repo root, assign `epoch="$(date +%s%N)$$"` on - [ ] `script/cibuild` — cd to repo root, then, each on its own line:
its own line, then run `docker build --build-arg CHECK_EPOCH="$epoch" .`
(what CI runs). The build arg is mandatory: see the `CHECK_EPOCH` rule in ```sh
`REPO_POLICIES.md` for why each element is load-bearing. A bare epoch="$(date +%s%N)$$"
`docker build .` fails closed by design. version="$(git describe --tags --always --dirty 2>/dev/null || true)"
[ -n "$version" ] || version="unknown"
docker build \
--build-arg CHECK_EPOCH="$epoch" \
--build-arg VERSION="$version" \
.
```
(what CI runs). Both build args are mandatory, and both assignments must be
on 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 `[ -n "$version" ]` line is a live check that fires on an
export with no `.git` and on a repo with no commits — keep it, and do not
collapse it into `|| echo unknown`, which makes it unreachable. See the
`CHECK_EPOCH` and git-describe rules in `REPO_POLICIES.md` for why each
element is load-bearing. A bare `docker build .` fails closed by design.
- [ ] `script/precommit` — called by the pre-commit hook; runs `script/check` - [ ] `script/precommit` — called by the pre-commit hook; runs `script/check`
- [ ] `script/install-precommit` — installs the pre-commit hook that runs - [ ] `script/install-precommit` — installs the pre-commit hook that runs
`script/precommit` `script/precommit`

View File

@@ -60,19 +60,21 @@ style conventions are in separate documents:
prerequisite since nvm requires bash. yarn is then pinned via prerequisite since nvm requires bash. yarn is then pinned via
`corepack prepare yarn@<version> --activate`. Never install "latest" or "lts"; `corepack prepare yarn@<version> --activate`. Never install "latest" or "lts";
always exact versions. `script/cibuild` runs the CI build: it changes to the always exact versions. `script/cibuild` runs the CI build: it changes to the
repo root and runs `docker build --build-arg CHECK_EPOCH="$epoch" .`, where repo root and runs
`epoch` is a per-invocation nonce (see the `CHECK_EPOCH` rule below); the `docker build --build-arg CHECK_EPOCH="$epoch" --build-arg VERSION="$version" .`,
Gitea workflow calls it. Four further scripts are our own extensions to the where `epoch` is a per-invocation nonce (see the `CHECK_EPOCH` rule below) and
standard: `script/check` runs `script/test`, `script/lint`, and `version` is computed on the host because `.git` is not in the build context
`script/fmt-check`; `script/precommit` is what the git pre-commit hook runs, (see the git-describe rule below); the Gitea workflow calls it. Four further
and it calls `script/check`; `script/install-precommit` installs the git scripts are our own extensions to the standard: `script/check` runs
pre-commit hook (the `make hooks` target shims to it); and `script/test`, `script/lint`, and `script/fmt-check`; `script/precommit` is
`script/projectname` (literally that filename) simply outputs the project's what the git pre-commit hook runs, and it calls `script/check`;
name. Scripts that need the name call `script/projectname` — e.g. `script/install-precommit` installs the git pre-commit hook (the `make hooks`
`script/docker` assembles its image tag from it — so those scripts stay target shims to it); and `script/projectname` (literally that filename) simply
byte-identical across all repos. Repo-type-specific pre-commit extras (e.g. outputs the project's name. Scripts that need the name call
`go mod tidy` verification in Go repos) belong in `script/precommit`, not in `script/projectname` — e.g. `script/docker` assembles its image tag from it —
the hook itself. Model scripts are at so those scripts stay byte-identical across all repos. Repo-type-specific
pre-commit extras (e.g. `go mod tidy` verification in Go repos) belong in
`script/precommit`, not in the hook itself. Model scripts are at
`https://git.eeqj.de/sneak/prompts/raw/branch/main/script/<name>`. The README `https://git.eeqj.de/sneak/prompts/raw/branch/main/script/<name>`. The README
must document the provided scripts in an **Entrypoints** section (see the must document the provided scripts in an **Entrypoints** section (see the
README requirements below). README requirements below).
@@ -122,11 +124,18 @@ style conventions are in separate documents:
```sh ```sh
epoch="$(date +%s%N)$$" epoch="$(date +%s%N)$$"
docker build --build-arg CHECK_EPOCH="$epoch" . version="$(git describe --tags --always --dirty 2>/dev/null || true)"
[ -n "$version" ] || version="unknown"
docker build \
--build-arg CHECK_EPOCH="$epoch" \
--build-arg VERSION="$version" \
.
``` ```
All four elements are load-bearing; none is optional, and each guards a The `VERSION` lines are there for a different reason, covered by the
failure mode that otherwise fails green: git-describe rule below; they are shown here so the two rules do not each
document half a command. All four `CHECK_EPOCH` elements are load-bearing;
none is optional, and each guards a failure mode that otherwise fails green:
- `ARG` is stage-scoped, so a single declaration leaves the other check - `ARG` is stage-scoped, so a single declaration leaves the other check
stages frozen while the fix reviews as complete. Declare it in every stage stages frozen while the fix reviews as complete. Declare it in every stage
that runs checks, immediately above the first such `RUN`. that runs checks, immediately above the first such `RUN`.
@@ -197,6 +206,9 @@ style conventions are in separate documents:
RUN [ -n "$CHECK_EPOCH" ] || exit 1 RUN [ -n "$CHECK_EPOCH" ] || exit 1
RUN echo "check epoch: ${CHECK_EPOCH}" && make test RUN echo "check epoch: ${CHECK_EPOCH}" && make test
# VERSION comes from the host via --build-arg; see the git-describe rule
# below. Never run `git describe` here: .dockerignore excludes .git, so
# it yields an empty version without failing the build.
ARG VERSION=dev ARG VERSION=dev
RUN CGO_ENABLED=0 go build -trimpath \ RUN CGO_ENABLED=0 go build -trimpath \
-ldflags="-s -w -X main.Version=${VERSION}" \ -ldflags="-s -w -X main.Version=${VERSION}" \
@@ -247,18 +259,24 @@ style conventions are in separate documents:
each stage is invalidated at two independent points. The later `RUN`s in each stage is invalidated at two independent points. The later `RUN`s in
the same stage need no expansion of their own: they are already the same stage need no expansion of their own: they are already
invalidated by their busted parent layer. invalidated by their busted parent layer.
- `ARG VERSION=dev` is declared in the build stage, and its value is
supplied on the host by `script/docker` and `script/cibuild` via
`--build-arg VERSION=...`. The `dev` default is a placeholder for a local
build, not a source of truth. **No stage may call `git describe`**:
`.dockerignore` excludes `.git`, so it yields an empty version without
failing. See the git-describe rule further down.
- Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that - Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that
runs `script/cibuild` (which runs runs `script/cibuild` (which runs
`docker build --build-arg CHECK_EPOCH="$epoch" .`) on push. The Dockerfile `docker build --build-arg CHECK_EPOCH="$epoch" --build-arg VERSION="$version" .`)
runs `make check`, so a successful build implies all checks pass — but that on push. The Dockerfile runs `make check`, so a successful build implies all
implication holds **only** because of the `CHECK_EPOCH` cache-bust described checks pass — but that implication holds **only** because of the `CHECK_EPOCH`
above. Without it, an unchanged tree serves the check layer from cache and the cache-bust described above. Without it, an unchanged tree serves the check
build reports a green it never earned. A bare `docker build .` fails closed by layer from cache and the build reports a green it never earned. A bare
design, on the `[ -n "$CHECK_EPOCH" ]` guard; always go through `docker build .` fails closed by design, on the `[ -n "$CHECK_EPOCH" ]` guard;
`script/cibuild` or `script/docker`. Never accept a `script/cibuild` pass as always go through `script/cibuild` or `script/docker`. Never accept a
evidence without confirming it ran: a sub-second wall time, or `CACHED` on the `script/cibuild` pass as evidence without confirming it ran: a sub-second wall
check layer, means nothing was executed. time, or `CACHED` on the check layer, means nothing was executed.
- 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
@@ -328,8 +346,10 @@ 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 which holds one worktree — an entire additional checkout of the repo — per
in-flight agent), language build artifacts, and `node_modules/`. 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. These patterns are written to `.gitignore`'s own semantics, in a new repo. These patterns are written to `.gitignore`'s own semantics, in
which an unanchored pattern already matches at every depth. They are not a which an unanchored pattern already matches at every depth. They are not a
@@ -349,7 +369,8 @@ style conventions are in separate documents:
depth-independent pattern the `**/` prefix — `**/node_modules`, depth-independent pattern the `**/` prefix — `**/node_modules`,
`**/.DS_Store`, and the secret patterns in the canonical file, which are `**/.DS_Store`, and the secret patterns in the canonical file, which are
additionally case-folded per the rule below — and leave only genuinely additionally case-folded per the rule below — and leave only genuinely
root-anchored entries such as `.git` unprefixed. The inverse move is equally root-anchored entries unprefixed: `.git`, the in-repo agent scratch directory
`.claude`, and the repo's own host-built binary. The inverse move is equally
wrong: never apply `**/` to `.gitignore`, where it is redundant and produces a wrong: never apply `**/` to `.gitignore`, where it is redundant and produces a
file that is wrong in a way that looks careful. Each file is written to its file that is wrong in a way that looks careful. Each file is written to its
own semantics; neither is derived from the other. Fetch the standard own semantics; neither is derived from the other. Fetch the standard
@@ -361,6 +382,33 @@ style conventions are in separate documents:
anchored, `/myapp` and never `**/myapp`: the prefixed form also matches anchored, `/myapp` and never `**/myapp`: the prefixed form also matches
`cmd/myapp/` and deletes the package directory from the context. `cmd/myapp/` and deletes the package directory from the context.
- **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 with `COPY . .` the build context
inflates by a multiple of the repo, and another session's unreviewed,
sometimes uncommitted work can be copied into an image layer. The directory is
also created and destroyed constantly, so it invalidates `COPY . .` for
reasons that have nothing to do with the repo's own content. In `.gitignore`
the entry is `.claude/`, unanchored, which already matches at every depth. In
`.dockerignore` it is `.claude`, anchored and with **no** `**/` prefix: the
directory occurs exactly once **where agents run at the repo root**, and the
prefixed form would also match any nested directory of that name and delete it
from the build. It is not case-folded the way the secret patterns are, because
tooling creates it in exactly one spelling, so a folded pattern would add no
coverage.
**Known gap that comes with the anchored form.** The directory is created in
the agent's working directory, so the "exactly once, at the root" premise is
a property of how agents are run and not of the tooling. Where agents run in
subdirectories — a monorepo with a per-service agent is the ordinary case —
`services/api/.claude/` is **not** excluded by the canonical entry and still
reaches the build context and the image, which is the exposure the entry
exists to close. A repo in that shape adds its own anchored entries
(`/services/api/.claude`), or `**/.claude` once it has confirmed no
legitimately named nested directory would be caught. This is stated in the
canonical `.dockerignore` itself, since that file is what consuming repos
receive.
- **`.dockerignore` matching is case-sensitive, so cover capitalisation with - **`.dockerignore` matching is case-sensitive, so cover capitalisation with
character classes rather than by doubling patterns.** `**/*.key` does not character classes rather than by doubling patterns.** `**/*.key` does not
match `certs/SERVER.KEY`, which is reachable on the case-insensitive match `certs/SERVER.KEY`, which is reachable on the case-insensitive
@@ -390,6 +438,49 @@ style conventions are in separate documents:
bytes, and BuildKit transfers only the delta from the previous build, so the bytes, and BuildKit transfers only the delta from the previous build, so the
reported size describes the transfer and not the contents of the image. reported size describes the transfer and not the contents of the image.
- **Excluding `.git` means `git describe` cannot run inside any build stage, and
it fails quietly there.** The `GOLDFLAGS` version-embedding pattern assumes
`.git` is present; in a build stage there is no repository, so `git describe`
writes nothing to stdout and the `-X main.Version=` value comes out **empty**
rather than erroring. The binary then reports no version at all and the build
still exits 0. Compute the version **on the host** and thread it in as a build
arg. `script/docker` and `script/cibuild` do this, byte-identically across
repos:
```sh
# Assign on its own line: a failing command substitution inside an
# argument does not trip `set -e`, so the inline form degrades to an
# empty constant — the same silent-empty failure this rule is about.
version="$(git describe --tags --always --dirty 2>/dev/null || true)"
[ -n "$version" ] || version="unknown"
docker build \
--build-arg CHECK_EPOCH="$epoch" \
--build-arg VERSION="$version" \
.
```
`--always` makes an untagged repo yield the abbreviated commit hash instead
of failing. `|| true` keeps a failing `git describe` from tripping `set -e`
and leaves the value empty, so the `[ -n "$version" ]` line is the single
place the fallback is applied — and it is a **live** check, not defence in
depth: it fires on a build from an export with no `.git`, and on a
repository with no commits yet. Do not fold the fallback into the
substitution as `|| echo unknown`; that makes the guard unreachable, and a
guard that cannot fire is indistinguishable from one that works to everyone
who copies it. The result is non-empty by construction either way, which is
the point: an empty version reads as a successful one, while `unknown` is
visibly wrong. The Dockerfile's side is `ARG VERSION=dev` in the stage that
compiles, declared there and not inherited, because `ARG` is stage-scoped
exactly as `CHECK_EPOCH` is. Passing `VERSION` to a repo whose Dockerfile
declares no such `ARG` is silently ignored by BuildKit and costs nothing,
which is why the scripts stay byte-identical rather than growing a per-repo
variant.
One consequence for CI: the standard checkout action clones shallow and
fetches no tags, so `git describe --tags` there falls back to a bare commit
hash. A repo that embeds a tag-derived version must set `fetch-depth: 0` on
its checkout step; a repo that does not embed a version needs no change.
- **No build artifacts in version control.** Code-derived data (compiled - **No build artifacts in version control.** Code-derived data (compiled
bundles, minified output, generated assets) must never be committed to the bundles, minified output, generated assets) must never be committed to the
repository if it can be avoided. The build process (e.g. Dockerfile, Makefile) repository if it can be avoided. The build process (e.g. Dockerfile, Makefile)

View File

@@ -14,7 +14,20 @@ main() {
# nonce to an empty constant. `$$` is required because busybox `date` # nonce to an empty constant. `$$` is required because busybox `date`
# drops %N without erroring. # drops %N without erroring.
epoch="$(date +%s%N)$$" epoch="$(date +%s%N)$$"
docker build --build-arg CHECK_EPOCH="$epoch" . # VERSION must be computed here, on the host: .dockerignore excludes
# .git, so `git describe` cannot run in any build stage and fails
# quietly there rather than erroring. Same own-line discipline as the
# epoch. `|| true` keeps a failing describe from tripping `set -e`
# and leaves the value empty; the guard below is then the single
# place the fallback is applied, and it does fire — on an export with
# no .git, or a repo with no commits yet. `unknown` is visibly wrong
# in a binary in a way that an empty version is not.
version="$(git describe --tags --always --dirty 2>/dev/null || true)"
[ -n "$version" ] || version="unknown"
docker build \
--build-arg CHECK_EPOCH="$epoch" \
--build-arg VERSION="$version" \
.
} }
main "$@" main "$@"

View File

@@ -16,7 +16,19 @@ main() {
# nonce to an empty constant. `$$` is required because busybox `date` # nonce to an empty constant. `$$` is required because busybox `date`
# drops %N without erroring. # drops %N without erroring.
epoch="$(date +%s%N)$$" epoch="$(date +%s%N)$$"
docker build --build-arg CHECK_EPOCH="$epoch" \ # VERSION must be computed here, on the host: .dockerignore excludes
# .git, so `git describe` cannot run in any build stage and fails
# quietly there rather than erroring. Same own-line discipline as the
# epoch. `|| true` keeps a failing describe from tripping `set -e`
# and leaves the value empty; the guard below is then the single
# place the fallback is applied, and it does fire — on an export with
# no .git, or a repo with no commits yet. `unknown` is visibly wrong
# in a binary in a way that an empty version is not.
version="$(git describe --tags --always --dirty 2>/dev/null || true)"
[ -n "$version" ] || version="unknown"
docker build \
--build-arg CHECK_EPOCH="$epoch" \
--build-arg VERSION="$version" \
-t "$("$SCRIPT_DIR/projectname")" . -t "$("$SCRIPT_DIR/projectname")" .
} }