AGENTS.md@docker · git:20260810.b624478 · 2026-08-10 · sha256 7c823d3ca3204aeb

AGENTS.md@docker git:20260810.b624478A

Immutable. This exact content is served forever at /api/v1/blob/7c823d3ca3204aeb.

# docker/ — build images for the dotfiles CI test image

All the Dockerfiles live here so the guidance below is discovered exactly when
you edit them (progressive loading — the root `AGENTS.md` only carries a
one-line pointer). Build them via the `Makefile`, never `docker build` by hand:

| File | What it is | Built by |
|------|------------|----------|
| `Dockerfile` | The CI **test image**: the repo installed on top of the base. Two-phase (see below). | `make build` (`-f docker/Dockerfile`, context = repo root) |
| `Dockerfile.base` | The **base image** (`ghcr.io/…/dotfiles-base`): ubuntu + apt tooling + docker/dind. Published from **main only** by `.github/workflows/docker-base.yml`. | `make build-base` |
| `helloworld/` | Tiny image used by the Build job's "Docker-in-Docker (Legacy Check)" step. | that CI step |
| `busybox-py/` | Minimal example/fixture image. | — |

The build **context stays the repo root** (`docker build … .`), so every `COPY`
in these Dockerfiles is root-relative — moving the files here changed nothing
about what they copy.

## The three-tier layering — where a new install step belongs (CI performance)

The **Build and Integration Test** job (the most expensive CI job) is kept fast
by a three-tier split. Put a new dependency, tool, package, or config step in
the RIGHT tier or you silently regress build time (or, worse, correctness):

1. **OS-level foundation → `Dockerfile.base`** (published from main only, rebuilt
   weekly). Truly foundational, rarely-changing things: base apt packages,
   docker/dind, the shell itself. Costly to validate — see the base-change note.
2. **App-level external install dependency → `install.sh` `--phase deps`** (the
   **cached** deps layer of `Dockerfile`). The rule of thumb: **if it's an
   external install — an apt/brew package, a downloaded tool/CLI, or a language
   runtime/toolchain (goenv/pyenv/rbenv/nvm) — it's a `deps` step.** Gate it with
   a `gff` flag and add that key to **`_IP_DEPS_FLAGS`** in `install.sh`. Kept in
   the app (not the base) so a PR that changes a deps installer still busts that
   layer and is **validated per PR**; unchanged, it's a cache hit and skipped.
3. **Config / skill / symlink / repo-content step → `install.sh` `--phase
   config`** (the per-commit layer). **If it consumes repo content — links a
   dotfile, syncs a skill/plugin, builds an sdk binary, runs a project script —
   it does NOT belong in deps.** Add its key to **`_IP_CONFIG_FLAGS`**.

**Keep the two lists in `install.sh` in sync with new sections.** Omitting a
deps key makes the step re-run every commit (slow); omitting a config key bakes
it into the cached deps layer so later edits stop taking effect per commit (a
correctness bug). `--phase all` (a normal `./install.sh`) applies no overrides,
so real machines are unaffected.

> **A key in NEITHER list runs in BOTH phases** — the failure mode that shipped
> in #217. The `install.sdk.*` keys were in neither, so every Go binary built
> twice: once in the cached deps layer (where it got baked in and stamped
> `Commit: none`, because that layer's partial `COPY` has no `.git`) and again in
> config, where it silently degraded to `WARNING: 'go' not found` — `go` is on
> `PATH` only in the layer where goenv ran, and `PATH` does not survive across
> `RUN` layers. CI stayed green throughout, because the deps-layer binaries
> existed and `gss version` worked.
>
> Two safeguards now exist: `ensure_go_on_path()` re-activates an
> already-installed goenv in any phase (a no-op when `go` is already present), and
> `install.sh` **exits non-zero** when a container phase (`deps`/`config`) still
> has no `go`, instead of letting the sdk builds skip with a warning.

**Go binaries belong in `--phase config`, never the base image.** They are
repo-content builds: they need the full `COPY` (including `.git`, for the version
stamp) and must rebuild per commit. Building them in the base image or the deps
layer ships stale, mis-stamped binaries.

**Changing `Dockerfile.base` in a PR? CI detects it automatically.** Because the
base publishes from main only, the Build job checks the PR's changed files and
**builds the base locally** (instead of pulling the stale published one) when a
PR touches `docker/Dockerfile.base` or `opt/scripts/docker/dockerd-entrypoint.sh`,
so your base change is validated in that PR. Deps/config changes need no special
handling — their app layers re-validate on their own.

## Caching

`make build` uses buildx; CI sets `BUILD_CACHE="--cache-from=type=gha
--cache-to=type=gha,mode=max"` so the deps layer persists across runs. Locally
`BUILD_CACHE` is empty (plain buildx; cache mounts still apply). See the
`Makefile` `build` / `build-base` targets and `.github/workflows/docker-*.yml`.