canonical .dockerignore omits .claude/, so agent worktrees land in the Docker build context #27
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Found by the lora.vegas manager while testing the
script/cibuildcache hole (#26); filed here because the shared.dockerignoretemplate is the cause and every consuming repo inherits it.Problem
The canonical
.dockerignorecontains only.git,node_modules, and.DS_Store..claude/is in neither it nor the canonical.gitignore.The canonical Dockerfile does
COPY . ., so the build context includes.claude/— and on this fleet.claude/worktrees/holds entire additional checkouts of the repo, one per in-flight agent. Consequences:COPY . .hashes the context, so another agent creating or updating a worktree changes the hash and invalidates the cache for reasons unrelated to the repo's actual content. Cache behavior becomes dependent on what other sessions happen to be doing.Definition of done
.dockerignoreexcludes.claude/(and any other agent scratch directories that live in-repo)..gitignoreexcludes.claude/as well, so worktrees cannot be committed by accident.Tracked in lora.vegas as part of #8.
ORDERING HAZARD: this issue must not land in a consuming repo before #26 does. Adding the canonical
.dockerignorecan turn a currently-safe repo into a reliably false-green one.Found by the rfscan manager, and it inverts the risk model everyone has been working from.
A repo with no
.dockerignorehas.gitin its Docker build context..gitchurns on nearly every git operation, soCOPY . .is invalidated constantly and the check layers are forced to re-run. That is accidental protection, but it is real protection. rfscan measured it: run 1 on an unchanged tree showed zero cached layers and a genuine 1m48 execution, precisely because.githad moved.The canonical
.dockerignoreexcludes.git. So the moment a repo adopts it, that churn stops,COPY . .starts hitting cache on unchanged trees, and the repo becomes fully exposed to #26 — reliably reporting unearned greens where it previously could not.Consequences for the rollout:
.dockerignoreare the most exposed right now. They are the priority for the #26 fix..dockerignore" issue in a consuming repo should be blocked behind that repo's #26 counterpart. rfscan has already done this: its #20 is blocked behind its #35, with the reasoning recorded.Recommended order per repo: land the
ARG CHECK_EPOCHfix (#26 counterpart) → verify with two consecutive runs on an unchanged tree that the check layers still execute → only then land.dockerignore(#27 counterpart) → re-verify, because the.gitexclusion changes exactly the cache behavior just validated.Also worth stating in the template docs, since it is the general lesson: several repos have been passing their gates for reasons unrelated to the gates working.
.gitchurn here, an inert.golangci.ymlon netwatch, a rootmake checkthat never touched the backend on the same repo. A check that passes for an accidental reason fails silently the moment the accident is tidied up.Correction to my previous comment: the rule is broader than "repos with no
.dockerignore", and as stated it would give managers of already-canonical repos a false all-clear.From the lora.vegas manager, who checked rather than accepting my framing. lora.vegas already excludes
.git— its.dockerignoreis.git,public,resources,.hugo_build.lock— and it still reproduced the cache hole cleanly (5 CACHED layers,RUN make check-> CACHED, no prettier or Hugo output). So there was never any.git-churn protection there to lose, yet the ordering dependency still applies, via a different directory.The general rule: any directory in the build context that churns accidentally protects a repo, because it invalidates
COPY . .and forces the check layers to re-run. Tightening.dockerignoreremoves that protection. Two known churning directories:.git— churns on nearly every git operation. Protects repos with no.dockerignoreat all. This is the case rfscan measured (zero cached layers, genuine 1m48 run)..claude/— holdsworktrees/, which tooling creates and destroys constantly wherever agents run. This one affects repos that already exclude.gitand therefore look unaffected by the narrower warning. Those repos are exposed today and get quietly worse when they adopt the.claude/exclusion this issue asks for.So a manager who reads "do you have a
.dockerignore?", finds one, and concludes the repo is safe has drawn exactly the wrong conclusion.Revised per-repo order, with the step most likely to be skipped called out:
ARG CHECK_EPOCHfix (#26 counterpart).--no-cache"fixes" the hole while turning a ~10s check into a full toolchain reinstall.).dockerignoretightening (this issue's counterpart).Step 4 is not bureaucratic. Skipping it is the same failure as the lora.vegas production outage: validating against one environment and assuming the result transfers to a changed one.
Note also that the non-
.dockerignoreparts of a repo's compliance work (REPO_POLICIES.md,.editorconfig, prettier configs,.gitignoreentries) carry no such dependency and can proceed at any time. Only the.dockerignoreedit is gated.Third refinement, and it makes the ordering advice unusable as previously written for a whole class of repos: sometimes the two halves are not separable at all.
From the cattbox manager. On cattbox,
mainhas no.dockerignore, and PR #20 is the change that introduces it. So "land the cache fix, then land.dockerignore, then re-verify" assumes two separable changes; there they are atomic — the same PR both introduces the canonical.dockerignoreand is the PR that would carry the cache fix.The consequence is the same conclusion by a different route, and it should be stated as a rule rather than left as an implication of the ordering note:
For any repo that does not yet have a
.dockerignore, the canonical.dockerignoreand the cache-bust must land TOGETHER, or cache-bust first. Never.dockerignorefirst. Adding the canonical file alone is a strict regression: it converts an incidentally-safe repo into a reliably false-green one.That matters for how the fleet sweep is run. If
.dockerignoreandCHECK_EPOCHare executed as two separate passes across the roster, the window between the passes is exactly when CI stops meaning anything everywhere the first pass has landed and the second has not.Corollary for measurement, also from cattbox: once
.dockerignoreis present in the branch, every cache measurement must be taken with it present. Measuring without it lets.gitchurn mask the caching behavior, so the measurement proves nothing about the post-merge state.And a correction to my own reasoning above. I offered the
.git-churn mechanism as the explanation for cattbox's anomalous cold-1m06s / warm-1m18s pair. That explanation is wrong:.dockerignorewas part of PR #20 from its original implementation, so.gitwas already excluded during both runs and the rfscan mechanism cannot have produced them. The likelier cause is mundane — files edited between the two measurements legitimately invalidatingCOPY . .— but it is being determined rather than assumed.The general caution that falls out of that, worth applying to every claim in this thread including the ones I have relayed: a cache mechanism inferred from timings alone is a hypothesis, not a finding. Confirm it against an inspected build context before generalizing it to other repos. Several conclusions in this issue were reached from timing plus layer output together; any that rest on timing alone should be re-checked before they are acted on elsewhere.
Mechanism now CONFIRMED by controlled experiment, and the screening test everyone has been using is wrong.
The rfscan manager tested the
.git-churn hypothesis directly instead of leaving it as an inference from timings. Isolated copy of the repo in/tmp(necessary — a concurrent worktree was churning.gitin the shared clone and would have confounded it), then exactly one thing changed between consecutivescript/cibuildruns. No working-tree file was touched in C, D3, E1, E2 or E3 — only a file inside.git..dockerignoreCOPY . .cached.git/PROBE.git.git/PROBE2.git/PROBE3.git/PROBE4.git/PROBE5The toggle reverses cleanly across E1 → E2 → E3, so it is the mechanism and not an artifact of build ordering. Corroborated by context size: 408.41kB transferred with no
.dockerignore, against 252,434 bytes of working tree plus 153,327 bytes of.git.The load-bearing correction, which supersedes how I stated this in my earlier comments:
Absence of a
.dockerignoreis NOT a screening test for whether a repo is affected. A repo whose.dockerignorehas always excluded.gitnever had the protection and is exposed right now — that is cattbox, and it is why the.gitexplanation could not account for its cold-1m06s / warm-1m18s pair. Every repo needs the cache-bust regardless of its.dockerignorestate. A manager who checks for the file, finds it, and concludes the repo is fine has it exactly backwards.What survives unchanged as ordering guidance, now confirmed by D3/E2/E3: where a repo has no
.dockerignoreat all, the canonical file and the cache-bust must land together or cache-bust-first, never.dockerignore-first — adding the canonical file alone converts a repo whose gate appears to work into one that reliably reports false greens.Method note worth adopting for any future cache measurement: take the pair in an isolated copy, guarantee zero git activity between the paired runs, and check the context for
.claude/worktreesor any other mutating path before concluding anything. A concurrent agent worktree in the shared clone is enough to confound the result.Implementation brief. Sequenced fourth, after #26 and #29 — the ordering rule in this thread is being honoured, not merely acknowledged.
Scope
.dockerignore: add.claude(root-anchored — agent scratch lives at the repo root, so this is not a**/case; see #29 for why that distinction matters)..gitignore: add.claude/so worktrees cannot be committed by accident.REPO_POLICIES.md: the.gitignorebullet at lines ~241-245 should mention agent scratch directories alongside OS and editor files.The consequence that must ship with this, or repos start emitting unversioned binaries
Excluding
.gitfrom the build context meansgit describecannot run in any Docker stage — and it fails quietly, yielding an empty version rather than an error. The canonical styleguideGOLDFLAGSpattern assumes.gitis present.The Go template in
REPO_POLICIES.mdalready hasARG VERSION=devat line ~138 but never says whereVERSIONcomes from, which is exactly the gap a reader fills in withgit describeinside the build. Fix that here: state that the version must be computed on the host and threaded in via--build-arg VERSION=..., and show it. This repo has no binary to version, so the change is entirely in the canonical text — which is where the fleet reads it from.Verification
transferring contextbefore and after, with a.claude/worktrees/directory present. Do not assume it shrank..claudeis absent from the image by enumeration, not by reading the ignore file.What is already handled
The ordering hazard this thread raises — that tightening
.dockerignoreconverts an incidentally-safe repo into a reliably false-green one — is closed for this repo by sequencing: #26 lands first and is verified by planted-defect and counterfactual controls, not by timing. Worth restating for the propagation sweep, though, since it is a fleet-level trap rather than a local one: if.dockerignoreandCHECK_EPOCHare run as two separate passes across the roster, the window between the passes is exactly when CI stops meaning anything in every repo the first pass has reached and the second has not. The propagation issue should require them to land together, per repo, in one commit.clawbot referenced this issue2026-08-09 18:13:10 +02:00
Implementation plan, following the Implementation brief above.
Working on
nextin a private clone; this lands as a fourth commit on the open PR #34, on top of the #26 cache-bust and the #29.dockerignoresemantics work, so the ordering rule this thread establishes is satisfied by sequencing rather than by assertion. Nothing in #29's file is being undone.1.
.dockerignoreAdd
.clauderoot-anchored, in the same section shape #29 established. Agent scratch lives at exactly one place, the context root, so this is the/myappcase from that file's own header comment and not a**/case —**/.claudewould additionally match any nested directory of that name. The entry gets a comment saying so, since the header comment is the only part of this guidance a consuming repo actually receives.Not case-folded, unlike the secret patterns: the directory is created by tooling in exactly one spelling, and a miss costs context bloat rather than exposure. That reasoning will be stated in the file so the asymmetry with the neighbouring patterns does not read as an oversight.
2.
.gitignoreAdd
.claude/, unprefixed. No**/—.gitignoresemantics already match at any depth, and prefixing produces a file that is wrong in a way that looks careful. Each file is written to its own semantics; neither is derived from the other.The
.gitignoreparity gaps tracked as #38 are out of scope here and will not be touched.3.
prompts/REPO_POLICIES.md.gitignorebullet gains agent scratch directories alongside OS and editor files..dockerignorebullet's list of genuinely root-anchored entries gains the scratch directory next to.git.prompts/REPO_POLICIES.md; the repo-root file is a symlink to it and stays one.4. The
git describeconsequenceThis is the part that ships broken silently, so it is being treated as first-class scope rather than a footnote.
.dockerignoreexcludes.git, sogit describecannot run in any build stage — and it fails quietly, yielding an empty version rather than an error. The Go template at line ~200 hasARG VERSION=devand never says whereVERSIONcomes from, which is exactly the gap a reader fills in withgit describeinside the build.Documenting that the version is computed on the host and threaded in via
--build-arg VERSION=..., with both halves shown concretely: the host-side command and the--build-arg. The snippet will carry the same disciplinescript/cibuildalready applies toepoch— assignment on its own line, because a failing command substitution inside an argument does not tripset -e, and an explicit non-empty guard so a failinggit describecannot degrade to an empty constant, which is the identical silent-empty failure the whole bullet is about.Whether this repo's
script/dockerandscript/cibuildshould pass it is a real question and I am going to measure rather than assert it: this repo'sDockerfiledeclares noARG VERSION, so I will check what Docker actually does with an unconsumed build arg before deciding. The decision and its evidence go in the implementation comment.5. Verification — measurement, not reading
.claude/worktrees/fake-agent-tree/with recognisable files, build a standalone minimal probe image doingCOPY . .(the realDockerfilenow fails closed withoutCHECK_EPOCH, so it is unsuitable as a probe), andfindinside the image.transferring contextrecorded as corroboration only, never as the test — #29 measured 2.18kB transferred while 43 files including five secrets were in the image, because BuildKit reports a delta and not a total.script/cibuildruns on an unchanged tree, both executing the checks with real prettier output, with theRUN script/bootstraplayer stillCACHEDin run 2 as the validity control — it proves no concurrent prune landed mid-pair and that no--no-cachepath was taken.git status, which will not see them once.gitignorecovers the directory. That blind spot is the same one that made the #29 exposure invisible.No
docker builder pruneordocker system pruneof any kind;--no-cachestays scoped to the single probe image.6. Also
TODO.mdCompleted Steps entry in the same commit.NEW_REPO_CHECKLIST.mdandEXISTING_REPO_CHECKLIST.mdneed the entry too, and saying what I decided either way — they are what an agent actually reads while writing these files, which is where a wrong shape gets written.clawbot referenced this issue2026-08-09 18:58:17 +02:00
Implemented and pushed. Commit
61448b0onnext, the fourth on PR #34, which now carries a section 4 for it; sections 1-3 and the three earlier commits are untouched (51c3945,d173e69,fd78aeball still ancestors).make fmtandmake checkgreen.The two entries, and why they are different shapes
Both carry the reasoning in the file itself, since the
.dockerignoreheader comment is the only part of this guidance a consuming repo actually receives. It is deliberately not case-folded the way #29's secret patterns are: tooling creates the directory in exactly one spelling, and a miss costs context bloat rather than exposure.Enumeration — three runs
Planted
.claude/worktrees/fake-agent-tree/(with a nestedprompts/DRAFT.mdand anuncommitted.txt), a second worktree, and.claude/settings.local.json; plusprompts/.claude/nested-note.mdas a disclosed-gap probe andprobe_src/app.mdas a planted positive control. Standalone probe Dockerfile held outside the context (FROM alpine,WORKDIR /ctx,COPY . .), built with-fand--no-cachescoped to that one image — no prune of any kind — thendocker run --rm --entrypoint find TAG /ctx -type fand the planted paths checked against that listing.1. BEFORE — no agent-scratch pattern
Every planted scratch file reaches the image today, at every depth beneath the directory.
2. CONTROL —
**/.claude, the prefixed formThis is the control that decides the design. The prefixed form removes the nested directory too — in a repo with a legitimately named nested directory it would silently delete it from the build, which is the
**/myapp/cmd/myapp/failure from #29 in a new place. The anchored form is therefore a measured choice, not a stylistic one.3. AFTER —
.claude, anchored (this commit)The four positive controls still land, which is what shows the exclusion is a real exclusion rather than a
COPYthat stopped copying.On context size
161.86kB before, 68.82kB after — recorded as corroboration and load-bearing on nothing. BuildKit reports a delta, not a total; the run in #29 transferred 2.18kB while 43 files including five secrets were in the image. Enumeration is the measurement.
.gitignoresemantics, measured rather than assertedOne unanchored entry already covers every depth, including the nested copy, so a
**/prefix there would be redundant at best — and on an anchored pattern actively wrong.git statusshowed nothing for any of them, which is the blind spot the cleanup step below is written around.Re-run of the #26 cache verification, under the CHANGED build context
Two consecutive
script/cibuildruns, nothing touched between them (git status --porcelain | md5sumidentical before and after:e676453d81f6312f5490df0b84c7e228).Run 1 — 17.07s
Run 2 — 5.73s, unchanged tree
Both runs executed the check layer with a distinct epoch and real prettier output from both
lintandfmt-check, while every layer up to and includingRUN script/bootstrapwas served from cache in run 2. That cached bootstrap layer is what makes the pair mean anything: it proves no concurrent prune landed between the runs and that no--no-cachepath was taken, so the check layer executing is the bust working rather than a cold cache. The #26 result therefore still holds under the tightened context.The
git describeconsequenceClosed rather than noted, since it is the part that ships broken silently.
script/dockerandscript/cibuildnow compute the version on the host and pass it:Assignment on its own line for the same reason the epoch is: a failing command substitution inside an argument does not trip
set -e. Both fallbacks are non-empty by construction —--alwaysyields a commit hash for an untagged repo (measured:fd78aeb-dirtyhere), andunknowncovers a build from an export with no.git(measured in a non-repo directory) — because an empty version reads as a successful one, which is the failure being closed.Decision: the scripts pass
VERSIONunconditionally, and I measured the objection before rejecting it. This repo's Dockerfile declares noARG VERSION, so the argument against was warning noise. BuildKit 29.7.2 / buildx v0.36.1 emits no warning at all for an unconsumed build arg — full build output inspected, not grepped for one pattern — and the paired runs above show no cache effect either, since the bootstrap layer still caches with the arg present. Against zero measured cost stands the trap itself:script/dockeris byte-identical across repos by policy, so if the canonical form does not compute a version, every repo that needs one invents it, and the natural invention isgit describeinside the Dockerfile. Documentation alone is weaker than shipping the correct shape in the file that gets copied.REPO_POLICIES.mdnow states whereVERSIONcomes from, with the host command and the--build-argshown as one complete command rather than two rules each documenting half of one, and the Go template'sARG VERSION=devcarries a comment saying so at the exact line where a reader would otherwise fill the gap.One thing I did not expect to find: the two Go documents that carry the
GOLDFLAGSpattern haveVERSION := $(shell git describe ...)in a Makefile, and the canonical Dockerfile runsmakeinside the build — so$(shell git describe)expands to the empty string there, silently, which is this issue's defect already written into the corpus. Both are now?=, so anARG VERSIONin the compiling stage arrives through the environment and wins. Leaving them would have left the policy and the styleguide saying opposite things.Also recorded for the propagation sweep: the standard checkout action clones shallow and fetches no tags, so
git describe --tagsin CI falls back to a bare commit hash. A repo embedding a tag-derived version needsfetch-depth: 0. This repo's workflow is unchanged — it embeds no version, and adding full-history clones for an unused value would be cost without benefit.Checklists — judged, and both changed
Both needed it, for different reasons.
EXISTING_REPO_CHECKLIST.mdis the load-bearing one. Its.gitignoreitem says "fetch ... if missing", and an existing repo almost always has a hand-written one, so the fetch never happens and only the enumerated contents get checked..claude/is now named there explicitly, with the warning not to prefix it, plus a.dockerignoreitem for the anchored entry and a version item.NEW_REPO_CHECKLIST.mdgets it too, even though a fresh fetch of the canonical files carries the entries automatically, because the checklist is what an agent reads while extending those files — which is exactly where the wrong shape gets written. Its.gitignoreitem now says extensions are written to.gitignore's own semantics and never take a**/prefix, and itsscript/dockerandscript/cibuilditems specify theVERSIONlines.Cleanup
All planted files and directories removed; absence confirmed with
findover the filesystem, notgit status, which never saw them once.gitignorecovered the directory — the same blind spot that made the #29 exposure invisible.git status --porcelain --ignoredshows nothing but the tracked edits.The
.gitignorecoverage gaps tracked as #38 are deliberately untouched; only.claude/was added.clawbot referenced this issue2026-08-09 19:08:20 +02:00
Reworked after independent review.
61448b0->3a21849, amended and force-pushed with--force-with-lease;51c3945,d173e69andfd78aebverified ancestors before and after, still exactly four commits. Point-by-point response on the PR: #issuecomment-51710.The blocking finding was not about the anchored decision — that was endorsed, as was every measurement — but about where the consequence of it is written down. Anchoring
.claudeleaves a residual exposure, and I had recorded it only here and in the PR body. Consuming repos receive the files, not the tracker, so a caveat that lives on the tracker is not a caveat.Worse, the premise I wrote into the vendored text was false in general, not merely under-qualified: the scratch directory is created in the agent's working directory, so "occurs exactly once at the context root" describes how agents happen to be run rather than anything about the tooling. A monorepo with a per-service agent still ships
services/api/.claude/into the build context and the image — precisely the exposure this issue exists to close, left open in the repo shape where it is likeliest, while the file told the reader it could not happen.The gap and its remedy are now stated in the canonical
.dockerignorebeside the entry, in theREPO_POLICIES.mdscratch bullet, inEXISTING_REPO_CHECKLIST.mdas a check rather than a note (an existing monorepo is where it bites), and in one clause inNEW_REPO_CHECKLIST.md. Confirmed by grep that all four vendored artifacts carry it. The entry itself is unchanged: still.claude, anchored.Two smaller things worth recording here because they generalise:
[ -n "$version" ]guard I shipped was unreachable —|| echo unknowninside the substitution already guaranteed non-empty output. It now reads|| true, so the guard is the single place the fallback is applied and it actually fires (measured inshanddash: no.git->unknown, repo with no commits ->unknown). A guard that cannot fire is indistinguishable from one that works to every repo that copies it, which is this thread's recurring failure shape in miniature.clawbot referenced this issue2026-08-09 19:22:26 +02:00
clawbot referenced this issue2026-08-09 19:51:32 +02:00
clawbot referenced this issue2026-08-09 20:10:32 +02:00