Keep in-repo agent scratch out of the build context and out of git (closes #27)
All checks were successful
check / check (push) Successful in 17s
All checks were successful
check / check (push) Successful in 17s
The canonical .dockerignore and .gitignore both omitted the in-repo agent scratch directory. On this fleet that directory holds one worktree per in-flight agent -- an entire additional checkout of the repo each -- so under `COPY . .` all of it reached the build context and the image. Measured on this repo before the change: five planted scratch files, at every depth beneath the directory, all present inside a probe image built from the real context. Three consequences, only the first of which is about size. The context inflates by a multiple of the repo. Another session's unreviewed and sometimes uncommitted work is copied into a build artifact. And the directory is created and destroyed constantly by tooling, so it invalidates `COPY . .` for reasons that have nothing to do with this repo's content -- which is the accidental cache protection described at length in the issue thread, and the reason this change was sequenced behind the CHECK_EPOCH bust rather than landed alongside the rest of the .dockerignore work. The two entries are deliberately different shapes, because the two files have different semantics and neither is derived from the other. In .dockerignore the entry is anchored, `.claude`, with no `**/` prefix: the directory occurs exactly once, at the context root, and the prefixed form additionally matches any nested directory of that name. Measured rather than argued -- the `**/`-prefixed control was built and enumerated too, and it removes prompts/.claude/ from the context as well, which in a repo with a legitimately named nested directory would silently delete it from the build. In .gitignore the entry is unanchored, `.claude/`, because a .gitignore pattern already matches at every depth; `git check-ignore -v` confirms it covering both .claude/ and prompts/.claude/, so a `**/` prefix there would be redundant at best, and on an anchored pattern it would be actively wrong. Anchoring buys that at the cost of a residual exposure, and the vendored files now say so rather than only asserting the reason to anchor. "Occurs exactly once, at the context root" is a property of how agents are run, not of the tooling: the directory is created in the agent's working directory, so a monorepo running a per-service agent in services/api/ still ships services/api/.claude/ into the context and the image -- the exact exposure this change exists to close, left open in the repo shape where it is likeliest. The .dockerignore header block, the REPO_POLICIES.md bullet and both checklists state the gap and the remedy (anchored entries for the subdirectories that have one, or `**/.claude` once no legitimately named nested directory would be caught). Consuming repos receive the files and not the tracker, so a caveat that lives only in a PR body is not a caveat. It is not case-folded the way the neighbouring secret patterns are: tooling creates the directory in exactly one spelling, so a folded pattern would add no coverage. The earlier justification -- that a miss costs bloat rather than exposure -- is gone, because it contradicted this issue's own framing, in which the cost of a miss is unreviewed work in an image layer. The second half of this change is the consequence that ships broken silently. Excluding .git means `git describe` cannot run in any build stage, and it fails quietly there rather than erroring: `-X main.Version=` comes out empty, the binary reports no version, and the build still exits 0. The Go template in REPO_POLICIES.md had `ARG VERSION=dev` and never said where VERSION came from, which is precisely the gap a reader fills in with `git describe` inside the build. It now says: computed on the host, threaded in with `--build-arg VERSION=...`, shown as a complete command rather than as two rules each documenting half of one. script/docker and script/cibuild do it, with the same discipline the epoch already has -- assignment on its own line, because a failing command substitution inside an argument does not trip `set -e`, plus a non-empty fallback so a build from an export with no .git reports `unknown` rather than an empty string that reads as a successful version. That fallback is applied in exactly one place, and that place can actually execute. `git describe ... || true` leaves the value empty when it fails, and the `[ -n "$version" ]` line is what substitutes `unknown`. Folding the fallback into the substitution as `|| echo unknown` would have left the guard unreachable -- harmless in itself, but a guard that cannot fire is indistinguishable from one that works to every repo that copies it, and canonical text should not carry a check that is decorative. Measured firing in both sh and dash: not a git repository -> unknown, repository with no commits yet -> unknown, this repository -> the describe output. The checklist now carries the guard line as well, so the vendored guidance and the vendored script no longer disagree. The scripts pass VERSION unconditionally rather than growing a per-repo variant. This repo's Dockerfile declares no `ARG VERSION`, and BuildKit was measured accepting the unconsumed arg silently -- no warning, no cache effect, confirmed by the paired runs below in which the bootstrap layer still caches. The alternative, leaving it to each repo, reintroduces the trap: a repo that needs a version and finds no VERSION in its scripts writes `git describe` into the Dockerfile, which is the failure being closed. The same correction reaches the two Go documents that carry the GOLDFLAGS pattern, since a `$(shell git describe)` evaluated inside a build stage is exactly this empty version. Both are now `?=`, and their comments say precisely when that matters: where a build stage compiles by invoking make, `ARG VERSION` puts the value in the environment and `?=` defers to it, whereas the canonical Go template compiles with `go build` directly and uses the Makefile on the host only -- still `?=`, so that a repo which later moves its build behind make does not silently start shipping an empty version. Leaving them as `:=` would have left the corpus telling a reader one thing in the policy and the opposite in the styleguide. Both repo checklists gain the entries too. They are what an agent reads while writing these files, so they are where the wrong shape actually gets written: the .gitignore item is the one an existing repo never re-fetches, and it now names `.claude/` explicitly along with the warning not to prefix it. Verification, by enumerating a probe image rather than by reading the patterns. Standalone minimal Dockerfile held outside the context, `--no-cache` scoped to that one image, no prune of any kind. Before: all five planted scratch files in the image, 42 files total. After: zero, 37 files total, with README.md, script/check, prompts/NEW_REPO_CHECKLIST.md and a planted probe_src/app.md all still present as positive controls, so the exclusion is a real exclusion and not a COPY that stopped copying. Transferred context fell from 161.86kB to 68.82kB, recorded as corroboration only: BuildKit reports a delta, not a total, and an earlier run in this repo transferred 2.18kB while shipping 43 files. The CHECK_EPOCH verification was re-run under the changed context, because the context moved underneath the earlier measurement. Two consecutive script/cibuild runs on an unchanged tree: run 1 in 17.07s, run 2 in 5.73s, both executing the check layer with a distinct epoch and real prettier output from both lint and fmt-check. The `RUN script/bootstrap` layer is CACHED in run 2, which is the validity control -- it proves no concurrent prune landed between the runs and that no --no-cache path was taken, so the check layer executing is the bust working rather than a cold cache. Planted files were removed afterwards and their absence confirmed against the filesystem with `find`, not against `git status`, which cannot see them once .gitignore covers the directory -- the same blind spot that made the earlier secret exposure invisible.
This commit is contained in:
@@ -10,7 +10,10 @@
|
||||
# context root. A host-built binary is the usual case, and it must be
|
||||
# written anchored: `/myapp`, never `**/myapp`. The prefixed form also
|
||||
# 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
|
||||
# `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
|
||||
# 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
|
||||
|
||||
# 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
|
||||
# the empty string) and the `prod.env` convention.
|
||||
**/*.[eE][nN][vV]
|
||||
|
||||
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/
|
||||
|
||||
|
||||
26
TODO.md
26
TODO.md
@@ -21,6 +21,32 @@ fmt-check, and commit.
|
||||
|
||||
# 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
|
||||
developer's local `.env`, `*.pem` or `*.key` was reaching the Docker build
|
||||
context under `COPY . .`, invisible to every git-based check because
|
||||
|
||||
@@ -49,7 +49,20 @@ last_modified: 2026-08-09
|
||||
```
|
||||
|
||||
```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)
|
||||
|
||||
GOLDFLAGS += -X main.Version=$(VERSION)
|
||||
|
||||
@@ -24,9 +24,14 @@ 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: `.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
|
||||
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.editorconfig`
|
||||
- [ ] `Dockerfile` and `.dockerignore` exist (fetch `.dockerignore` from
|
||||
@@ -42,6 +47,26 @@ with your task.
|
||||
`/myapp`, never `**/myapp`, which would also match `cmd/myapp/`. An
|
||||
existing repo is where such a binary is likeliest to already be sitting in
|
||||
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;
|
||||
only genuinely root-anchored entries such as `.git` are unprefixed, and
|
||||
`.gitignore`'s patterns have not been transplanted unmodified.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: Go HTTP Server Conventions
|
||||
last_modified: 2026-02-22
|
||||
last_modified: 2026-08-09
|
||||
---
|
||||
|
||||
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. See the git-describe rule in REPO_POLICIES.md.
|
||||
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
|
||||
@@ -59,7 +63,16 @@ Template files can be fetched from:
|
||||
`.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. 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
|
||||
containing a check-running `RUN` must declare `ARG CHECK_EPOCH` with 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`
|
||||
for the image tag)
|
||||
- [ ] `script/docker` / `make docker` — builds Docker image, tagged via
|
||||
`script/projectname` (byte-identical across repos); assigns
|
||||
`epoch="$(date +%s%N)$$"` on its own line and passes
|
||||
`--build-arg CHECK_EPOCH="$epoch"`
|
||||
- [ ] `script/cibuild` — cd to repo root, assign `epoch="$(date +%s%N)$$"` on
|
||||
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
|
||||
`REPO_POLICIES.md` for why each element is load-bearing. A bare
|
||||
`docker build .` fails closed by design.
|
||||
`script/projectname` (byte-identical across repos); carries the same three
|
||||
version lines as `script/cibuild` below, and passes
|
||||
`--build-arg CHECK_EPOCH="$epoch"` and `--build-arg VERSION="$version"`
|
||||
- [ ] `script/cibuild` — cd to repo root, then, each on its own line:
|
||||
|
||||
```sh
|
||||
epoch="$(date +%s%N)$$"
|
||||
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/install-precommit` — installs the pre-commit hook that runs
|
||||
`script/precommit`
|
||||
|
||||
@@ -60,19 +60,21 @@ style conventions are in separate documents:
|
||||
prerequisite since nvm requires bash. yarn is then pinned via
|
||||
`corepack prepare yarn@<version> --activate`. Never install "latest" or "lts";
|
||||
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
|
||||
`epoch` is a per-invocation nonce (see the `CHECK_EPOCH` rule below); the
|
||||
Gitea workflow calls it. Four further scripts are our own extensions to the
|
||||
standard: `script/check` runs `script/test`, `script/lint`, and
|
||||
`script/fmt-check`; `script/precommit` is what the git pre-commit hook runs,
|
||||
and it calls `script/check`; `script/install-precommit` installs the git
|
||||
pre-commit hook (the `make hooks` target shims to it); and
|
||||
`script/projectname` (literally that filename) simply outputs the project's
|
||||
name. Scripts that need the name call `script/projectname` — e.g.
|
||||
`script/docker` assembles its image tag from it — 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
|
||||
repo root and runs
|
||||
`docker build --build-arg CHECK_EPOCH="$epoch" --build-arg VERSION="$version" .`,
|
||||
where `epoch` is a per-invocation nonce (see the `CHECK_EPOCH` rule below) and
|
||||
`version` is computed on the host because `.git` is not in the build context
|
||||
(see the git-describe rule below); the Gitea workflow calls it. Four further
|
||||
scripts are our own extensions to the standard: `script/check` runs
|
||||
`script/test`, `script/lint`, and `script/fmt-check`; `script/precommit` is
|
||||
what the git pre-commit hook runs, and it calls `script/check`;
|
||||
`script/install-precommit` installs the git pre-commit hook (the `make hooks`
|
||||
target shims to it); and `script/projectname` (literally that filename) simply
|
||||
outputs the project's name. Scripts that need the name call
|
||||
`script/projectname` — e.g. `script/docker` assembles its image tag from it —
|
||||
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
|
||||
must document the provided scripts in an **Entrypoints** section (see the
|
||||
README requirements below).
|
||||
@@ -122,11 +124,18 @@ style conventions are in separate documents:
|
||||
|
||||
```sh
|
||||
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
|
||||
failure mode that otherwise fails green:
|
||||
The `VERSION` lines are there for a different reason, covered by the
|
||||
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
|
||||
stages frozen while the fix reviews as complete. Declare it in every stage
|
||||
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 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
|
||||
RUN CGO_ENABLED=0 go build -trimpath \
|
||||
-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
|
||||
the same stage need no expansion of their own: they are already
|
||||
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
|
||||
runs `script/cibuild` (which runs
|
||||
`docker build --build-arg CHECK_EPOCH="$epoch" .`) on push. The Dockerfile
|
||||
runs `make check`, so a successful build implies all checks pass — but that
|
||||
implication holds **only** because of the `CHECK_EPOCH` cache-bust described
|
||||
above. Without it, an unchanged tree serves the check layer from cache and the
|
||||
build reports a green it never earned. A bare `docker build .` fails closed by
|
||||
design, on the `[ -n "$CHECK_EPOCH" ]` guard; always go through
|
||||
`script/cibuild` or `script/docker`. Never accept a `script/cibuild` pass as
|
||||
evidence without confirming it ran: a sub-second wall time, or `CACHED` on the
|
||||
check layer, means nothing was executed.
|
||||
`docker build --build-arg CHECK_EPOCH="$epoch" --build-arg VERSION="$version" .`)
|
||||
on push. The Dockerfile runs `make check`, so a successful build implies all
|
||||
checks pass — but that implication holds **only** because of the `CHECK_EPOCH`
|
||||
cache-bust described above. Without it, an unchanged tree serves the check
|
||||
layer from cache and the build reports a green it never earned. A bare
|
||||
`docker build .` fails closed by design, on the `[ -n "$CHECK_EPOCH" ]` guard;
|
||||
always go through `script/cibuild` or `script/docker`. Never accept a
|
||||
`script/cibuild` pass as evidence without confirming it ran: a sub-second wall
|
||||
time, or `CACHED` on the check layer, means nothing was executed.
|
||||
|
||||
- Use platform-standard formatters: `black` for Python, `prettier` for
|
||||
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.
|
||||
|
||||
- `.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
|
||||
editor files (`.swp`, `*~`), in-repo agent scratch directories (`.claude/`,
|
||||
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
|
||||
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
|
||||
@@ -349,7 +369,8 @@ style conventions are in separate documents:
|
||||
depth-independent pattern the `**/` prefix — `**/node_modules`,
|
||||
`**/.DS_Store`, and the secret patterns in the canonical file, which are
|
||||
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
|
||||
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
|
||||
@@ -361,6 +382,33 @@ style conventions are in separate documents:
|
||||
anchored, `/myapp` and never `**/myapp`: the prefixed form also matches
|
||||
`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
|
||||
character classes rather than by doubling patterns.** `**/*.key` does not
|
||||
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
|
||||
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
|
||||
bundles, minified output, generated assets) must never be committed to the
|
||||
repository if it can be avoided. The build process (e.g. Dockerfile, Makefile)
|
||||
|
||||
@@ -14,7 +14,20 @@ main() {
|
||||
# nonce to an empty constant. `$$` is required because busybox `date`
|
||||
# drops %N without erroring.
|
||||
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 "$@"
|
||||
|
||||
@@ -16,7 +16,19 @@ main() {
|
||||
# nonce to an empty constant. `$$` is required because busybox `date`
|
||||
# drops %N without erroring.
|
||||
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")" .
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user