From 51df10e1f7d05b46fddd458e3f602d9cb95eef7e Mon Sep 17 00:00:00 2001 From: sneak Date: Tue, 8 Sep 2026 04:58:31 +0000 Subject: [PATCH] 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 --- .dockerignore | 9 +++++ .gitignore | 6 ++++ README.md | 11 +++--- TODO.md | 6 ++++ prompts/CODE_STYLEGUIDE_GO.md | 9 ++++- prompts/EXISTING_REPO_CHECKLIST.md | 19 ++++++++-- prompts/GO_HTTP_SERVER_CONVENTIONS.md | 11 ++++-- prompts/NEW_REPO_CHECKLIST.md | 17 +++++++-- prompts/REPO_POLICIES.md | 52 +++++++++++++++++++++++---- script/cibuild | 9 ++++- script/docker | 11 +++++- 11 files changed, 140 insertions(+), 20 deletions(-) diff --git a/.dockerignore b/.dockerignore index d8b9731..fc4fd74 100644 --- a/.dockerignore +++ b/.dockerignore @@ -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`. diff --git a/.gitignore b/.gitignore index c5a0882..3558b76 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/README.md b/README.md index 169f3c7..261beb1 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/TODO.md b/TODO.md index 6b54871..257547e 100644 --- a/TODO.md +++ b/TODO.md @@ -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 diff --git a/prompts/CODE_STYLEGUIDE_GO.md b/prompts/CODE_STYLEGUIDE_GO.md index 39c4957..c0756ff 100644 --- a/prompts/CODE_STYLEGUIDE_GO.md +++ b/prompts/CODE_STYLEGUIDE_GO.md @@ -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) diff --git a/prompts/EXISTING_REPO_CHECKLIST.md b/prompts/EXISTING_REPO_CHECKLIST.md index 1d5ab1c..6c13a30 100644 --- a/prompts/EXISTING_REPO_CHECKLIST.md +++ b/prompts/EXISTING_REPO_CHECKLIST.md @@ -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` diff --git a/prompts/GO_HTTP_SERVER_CONVENTIONS.md b/prompts/GO_HTTP_SERVER_CONVENTIONS.md index 3a53b3c..3ee41d2 100644 --- a/prompts/GO_HTTP_SERVER_CONVENTIONS.md +++ b/prompts/GO_HTTP_SERVER_CONVENTIONS.md @@ -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: diff --git a/prompts/NEW_REPO_CHECKLIST.md b/prompts/NEW_REPO_CHECKLIST.md index df92f15..8b7296e 100644 --- a/prompts/NEW_REPO_CHECKLIST.md +++ b/prompts/NEW_REPO_CHECKLIST.md @@ -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` diff --git a/prompts/REPO_POLICIES.md b/prompts/REPO_POLICIES.md index 061dd1c..be9c138 100644 --- a/prompts/REPO_POLICIES.md +++ b/prompts/REPO_POLICIES.md @@ -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 diff --git a/script/cibuild b/script/cibuild index 51d3b80..4ad2f9c 100755 --- a/script/cibuild +++ b/script/cibuild @@ -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 "$@" diff --git a/script/docker b/script/docker index f72ee15..222a355 100755 --- a/script/docker +++ b/script/docker @@ -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 "$@"