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:
@@ -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
6
.gitignore
vendored
@@ -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/
|
||||
|
||||
|
||||
11
README.md
11
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
|
||||
|
||||
6
TODO.md
6
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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 "$@"
|
||||
|
||||
@@ -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 "$@"
|
||||
|
||||
Reference in New Issue
Block a user