Keep in-repo agent scratch out of the build context and out of git (closes #27)

The canonical .dockerignore and .gitignore both omitted the in-repo agent
scratch directory, which holds one worktree per in-flight agent, so under
`COPY . .` an entire extra checkout of the repo reached the image. The
two entries are deliberately different shapes: anchored in .dockerignore,
where the `**/` form would also delete a legitimately named nested
directory, and unanchored in .gitignore, where a pattern already matches
at every depth. Anchoring leaves a gap where agents run in
subdirectories, stated in the vendored file itself. The second half is
the consequence of excluding .git: `git describe` in a build stage yields
an empty version without erroring, so the version is now computed on the
host and passed in.

Model: opus-5
This commit is contained in:
2026-09-08 04:58:31 +00:00
parent a8905f5fe7
commit 51df10e1f7
11 changed files with 140 additions and 20 deletions

View File

@@ -12,8 +12,17 @@
# 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.
# Excluding .git means `git describe` cannot run in any build stage and
# fails quietly there; pass the version in with --build-arg VERSION.
.git
# 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`.

6
.gitignore vendored
View File

@@ -11,6 +11,12 @@ Thumbs.db
.vscode/
*.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_modules/

View File

@@ -123,10 +123,13 @@ alpine. We provide:
- `script/check` — run all checks: `test`, `lint`, `fmt-check` (our own
extension)
- `script/docker` — build the Docker image, tagged via `script/projectname`
(byte-identical across repos); builds with `--no-cache`, like `script/cibuild`
- `script/cibuild` — cd to the repo root and `docker build --no-cache .` (what
CI runs; the image build runs `script/check`, and `--no-cache` is what stops
Docker serving those checks from cache on an unchanged tree)
(byte-identical across repos); same `--no-cache` and `VERSION` as
`script/cibuild`
- `script/cibuild` — cd to the repo root, compute `version` from `git describe`,
then `docker build --no-cache --build-arg VERSION="$version" .` (what CI runs;
the image build runs `script/check`, `--no-cache` is what stops Docker serving
those checks from cache on an unchanged tree, and the version is computed on
the host because `.dockerignore` excludes `.git`)
- `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

View File

@@ -21,6 +21,12 @@ fmt-check, and commit.
# Completed Steps
- 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

View File

@@ -49,7 +49,14 @@ last_modified: 2026-09-08
```
```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. The version is computed on the
# host by `script/docker` / `script/cibuild` and passed with
# `--build-arg VERSION=...`; where a build stage invokes make,
# `ARG VERSION` puts it in the environment and `?=` defers to it.
VERSION ?= $(shell git describe --always --dirty)
BUILDARCH := $(shell uname -m)
GOLDFLAGS += -X main.Version=$(VERSION)

View File

@@ -24,9 +24,11 @@ with your task.
- [ ] `LICENSE` file exists and matches the README
- [ ] `REPO_POLICIES.md` exists and version date is current — fetch from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/REPO_POLICIES.md`
- [ ] `.gitignore` is comprehensive (OS, editor, language artifacts, secrets) —
fetch from `https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore`
if missing
- [ ] `.gitignore` is comprehensive (OS, editor, agent scratch, language
artifacts, secrets) — 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.
- [ ] `.editorconfig` exists — fetch from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.editorconfig`
- [ ] `Dockerfile` and `.dockerignore` exist; Dockerfile runs `make check` as a
@@ -42,6 +44,17 @@ with your task.
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, that version is computed on the
host and passed with `--build-arg VERSION=...` by `script/docker` and
`script/cibuild`, and no stage calls `git describe`. A tag-derived version
additionally needs `fetch-depth: 0` on the CI checkout step, which clones
shallow and fetches no tags by default.
- [ ] Gitea Actions workflow in `.gitea/workflows/` runs `script/cibuild` on
push — reference
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitea/workflows/check.yml`

View File

@@ -1,6 +1,6 @@
---
title: Go HTTP Server Conventions
last_modified: 2026-02-22
last_modified: 2026-09-08
---
This document defines the architectural patterns, design decisions, and
@@ -991,7 +991,14 @@ func main() {
Use ldflags to inject version information at build time:
```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.
VERSION ?= $(shell git describe --tags --always)
BUILDARCH := $(shell go env GOARCH)
build:

View File

@@ -35,7 +35,11 @@ Template files can be fetched from:
- [ ] `.gitignore` — fetch from
`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
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.editorconfig`
- [ ] `Makefile` — fetch from
@@ -58,7 +62,16 @@ Template files can be fetched from:
`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.
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, the version is computed on the
host and passed with `--build-arg VERSION=...`, and `ARG VERSION=dev` is
declared in the stage that compiles. **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
- Server: also builds and runs the application
- Non-server: brings up dev environment and runs `make check`

View File

@@ -254,12 +254,12 @@ style conventions are in separate documents:
must be in `.gitignore`. No exceptions.
- `.gitignore` should be comprehensive from the start: OS files (`.DS_Store`),
editor files (`.swp`, `*~`), language build artifacts, and `node_modules/`.
Fetch the standard `.gitignore` from
`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
which an unanchored pattern already matches at every depth; they are not a
`.dockerignore` and must not be transplanted into one unmodified.
editor files (`.swp`, `*~`), in-repo agent scratch directories (`.claude/`),
language build artifacts, and `node_modules/`. Fetch the standard `.gitignore`
from `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 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
@@ -283,6 +283,46 @@ style conventions are in separate documents:
`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.
- **Excluding `.git` means `git describe` cannot run inside any build stage, and
it fails quietly there.** In a build stage there is no repository, so
`git describe` writes nothing to stdout, `-X main.Version=` comes out empty,
the binary 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
# 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"
docker build --no-cache --build-arg VERSION="$version" .
```
`--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=dev` 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 a repo that embeds a
tag-derived version must set `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

View File

@@ -8,7 +8,14 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
docker build --no-cache .
# Own line: a failing command substitution inside an argument does
# not trip `set -e`, so the inline form degrades silently to an
# empty constant. VERSION is computed here because .dockerignore
# excludes .git, so `git describe` in a build stage yields an empty
# version without failing.
version="$(git describe --tags --always --dirty 2>/dev/null || true)"
[ -n "$version" ] || version="unknown"
docker build --no-cache --build-arg VERSION="$version" .
}
main "$@"

View File

@@ -10,7 +10,16 @@ ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() {
cd "$ROOT"
docker build --no-cache -t "$("$SCRIPT_DIR/projectname")" .
# Own line: a failing command substitution inside an argument does
# not trip `set -e`, so the inline form degrades silently to an
# empty constant. VERSION is computed here because .dockerignore
# excludes .git, so `git describe` in a build stage yields an empty
# version without failing.
version="$(git describe --tags --always --dirty 2>/dev/null || true)"
[ -n "$version" ] || version="unknown"
docker build --no-cache \
--build-arg VERSION="$version" \
-t "$("$SCRIPT_DIR/projectname")" .
}
main "$@"