Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions .devcontainer/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# What the prebuild tag is allowed to notice.
#
# devpod hashes the build context as well as the build config, and with a
# Dockerfile that COPYs nothing it hashes the *whole* directory: every file
# under the context, keyed by path and CRC32, with only its own feature-staging
# folder excluded (pkg/util/hash.DirectoryHash, reached from
# pkg/devcontainer/config.CalculatePrebuildHash). There was no .dockerignore in
# here, so the context half of the hash was "every byte in .devcontainer/",
# which is a wider claim than the tag is meant to make: the tag is supposed to
# move when the image would differ, and three kinds of file in here cannot
# change the image at all.
#
# Measured on devpod 0.26.1, amd64, against this repository's `.devcontainer/`
# at 4db3427: appending one comment line to claude-code/README.md and one to
# claude-code/init-host.sh moved the tag from
# devpod-5bf7be3e3e7e1b3f4fbb01a9b3ab88e7 -- which CI had published, so it
# pulled -- to devpod-90e9641b9d3661f681e640eeedb3a410, which nothing has ever
# published. That is a multi-minute local build, per launch, on every branch
# carrying the edit, bought with a prose change.
#
# Nothing here is excluded from the *build* in any meaningful sense, which is
# what makes this safe rather than a trade: the Dockerfile has no COPY and no
# ADD, so the context tar is not read at all. The one guard against that
# changing is a test -- test_the_dockerfile_copies_nothing_out_of_the_context.

# Prose. The feature's README and troubleshooting guide are here because the
# devcontainer Features spec wants them beside the feature they document, not
# because anything reads them at build time.
**/*.md

# The host-side hook. `initializeCommand` runs on the developer's machine
# before the container exists, and devpod already agrees it is not a build
# input: normalizeConfigForHash clears DevContainerActions, which is where the
# command lives, out of the config half of the hash. It then hashed the script
# itself straight back in as context. Excluding it here is what makes the two
# halves say the same thing.
claude-code/init-host.sh

# The manifest itself, for the same reason and with the most to gain from it.
# devpod does not read devcontainer.json out of the context -- it read the file
# to get here -- and the config half of the hash already carries every build
# input it declares: `name`, `features`, `overrideFeatureInstallOrder`, `image`,
# `dockerfile`, `context` and the whole of `build`. Hashing the raw bytes on top
# of that adds nothing except the comments, and this file is mostly comments:
# `abf6d9b` moved the tag with ~30 lines of them beside a one-line mount change.
#
# It also stops the raw bytes overruling devpod on the fields it deliberately
# drops -- `mounts`, `containerEnv`, `postCreateCommand`, `customizations` --
# all of which devpod applies when it creates the container, so a prebuilt image
# plus a changed one of them is correct and was being rebuilt for nothing.
# Verified both directions: a comment-only edit leaves the tag alone, while
# changing `build.cacheFrom` or adding a feature still moves it.
devcontainer.json
24 changes: 15 additions & 9 deletions .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,22 @@
// is a hash *of the build context* as well as of the config
// (pkg/devcontainer/config/prebuild.go). With the root as context that
// hash moved on every commit to any file in the repository, so a
// prebuilt image could never match one. Scoped here it moves when
// `.devcontainer/**` moves, which is exactly when the image has to be
// rebuilt -- this Dockerfile and the local `claude-code` feature's
// scripts included, since they sit inside the context and are hashed
// with it.
// prebuilt image could never match one.
//
// The root `.dockerignore` is no longer read for this build (docker
// looks for one inside the context); its patterns existed to keep
// `.pixi/` and `rust/target/` out of a root-sized context, and nothing
// of that shape is in here.
// Scoping it here narrowed that to "every commit under
// `.devcontainer/**`", which is close to right but not the same claim as
// "every commit that changes the image", and the gap cost real launches.
// A Dockerfile with no COPY leaves devpod's includeFiles empty, and it
// then hashes the whole directory -- so this file's own comments, the
// feature's README, and the host-side `initializeCommand` were all
// hashed into the tag, none of which the image can see.
// `.dockerignore`, below, is what closes it; read that file for the
// measurement and the reasoning.
//
// The root `.dockerignore` is not read for this build -- docker looks
// for one inside the context, and there is one there now. The root
// file's patterns existed to keep `.pixi/` and `rust/target/` out of a
// root-sized context, and nothing of that shape is in here.
"context": ".",

// Best-effort layer cache for builders that know nothing about devpod
Expand Down
19 changes: 14 additions & 5 deletions .github/workflows/devcontainer-prebuild.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,20 @@ name: Devcontainer prebuild
on:
push:
branches: [ "main" ]
# Exactly the inputs to the hash. `build.context` is `.devcontainer`, so the
# hashed context is this directory and nothing else -- which is what makes a
# path filter honest here rather than an optimisation that silently ships a
# stale image. A commit that changes `devlaunch/`, `rust/` or `pyproject.toml`
# cannot change the tag, so there is nothing for this workflow to do.
# A superset of the inputs to the hash, which is the safe direction to err in.
# `build.context` is `.devcontainer`, so nothing outside this directory can
# move the tag -- a commit to `devlaunch/`, `rust/` or `pyproject.toml` has
# nothing for this workflow to do. Inside it, `.devcontainer/.dockerignore`
# narrows the hash further, to the files the image is actually built from, so
# some commits this filter catches cannot move the tag either. Those cost a
# registry lookup and nothing else: `devpod build` finds the prebuild already
# published and returns without rebuilding or repushing.
#
# Narrowing the filter to match the hash exactly is the change not to make.
# It would mean restating `.dockerignore`'s patterns here, in a second place,
# inverted -- and the failure mode of getting that wrong is a commit that
# moved the tag with no image published at it, which is a silent return to
# local builds for everyone on `main`.
#
# The workflow file itself is in the list because a change to how the image
# is built or tagged should produce one.
Expand Down
34 changes: 34 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,40 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed

- **Editing prose under `.devcontainer/` no longer throws away the prebuilt
container.** Opening this repository with `dl` pulls a published image instead
of building one, and it had gone back to building. The prebuild tag is a hash
of the build context, and with a Dockerfile that `COPY`s nothing devpod hashes
the *whole* context directory -- so the feature's `README.md`, its
`TROUBLESHOOTING.md`, the host-side `initializeCommand`, and this manifest's
own comments were all hashed into a tag that is supposed to move only when the
image would differ. None of the four can reach the image.

Measured on devpod 0.26.1, amd64, at `4db3427`: one comment line appended to
`claude-code/README.md` and one to `claude-code/init-host.sh` moved the tag
from `devpod-5bf7be3e3e7e1b3f4fbb01a9b3ab88e7`, which CI had published and
every launch pulled, to `devpod-90e9641b9d3661f681e640eeedb3a410`, which
nothing had ever built. The cost was several minutes per launch, on every
branch carrying the edit, and the only symptom was that opening a container
was slow again -- devpod treats an unmatched tag as a cache miss and builds
locally without complaint.

`.devcontainer/.dockerignore` is the fix, and the narrowing is checked in both
directions rather than only the useful one. Prose, the host hook and
`devcontainer.json` stop moving the tag; the Dockerfile,
`claude-code/install.sh`, `claude-code/devcontainer-feature.json` and the
build inputs the manifest declares still do -- `build.cacheFrom` and an added
feature were both confirmed to move it, since devpod hashes those from the
parsed config rather than from the file. An exclusion too wide would be the
worse bug: a launch pulling an image built from a Dockerfile the branch no
longer has, coming up fine and being the wrong container.

**One rebuild is still owed.** The `.dockerignore` is itself part of the
context, so this commit moves the tag once; launches build locally until
`devcontainer-prebuild.yml` republishes on `main`.

## [0.35.0] - 2026-09-09

### Added
Expand Down
28 changes: 20 additions & 8 deletions docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -396,14 +396,26 @@ Two consequences worth knowing:
what makes the tag usable. The Dockerfile copies nothing out of the context,
so the root was never needed, but it was hashed, which meant a different tag
on every commit to any file and a prebuilt image that never matched one.
Scoped to `.devcontainer`, the tag moves when `.devcontainer/**` moves, the
Dockerfile and the local feature's scripts included.
- **A commit whose `.devcontainer/` differs from the last prebuild builds
locally.** That is the correct answer rather than a gap: the alternative is a
container built from something other than what the branch asks for. The pull
comes back once the change is on `main`. What the tag does not promise is the
converse, that one `.devcontainer/` tree always yields one image; see "What
the prebuild tag does not promise" below.
- **Inside `.devcontainer/`, only the files the image is built from are
hashed**, and `.devcontainer/.dockerignore` is what draws that line. Scoping
the context to this directory was not enough on its own: a Dockerfile with no
`COPY` leaves devpod's `includeFiles` empty and it hashes the whole directory,
so the feature's `README.md`, its `TROUBLESHOOTING.md`, the host-side
`initializeCommand` and `devcontainer.json`'s own comments all moved the tag
without changing the image. Measured on devpod 0.26.1 at `4db3427`: one
comment line appended to `claude-code/README.md` and one to
`claude-code/init-host.sh` moved the tag off the published
`devpod-5bf7be3e3e7e1b3f4fbb01a9b3ab88e7` and onto a tag nothing had ever
built. What still moves it: the Dockerfile, `claude-code/install.sh`,
`claude-code/devcontainer-feature.json`, and the build inputs
`devcontainer.json` declares -- `build`, `features`, `image`, `name` -- which
devpod hashes from the parsed config rather than from the file.
- **A commit that changes what the image is built from builds locally until it
reaches `main`.** That is the correct answer rather than a gap: the
alternative is a container built from something other than what the branch
asks for. The pull comes back once the change is on `main`. What the tag does
not promise is the converse, that one `.devcontainer/` tree always yields one
image; see "What the prebuild tag does not promise" below.

`.github/workflows/devcontainer-prebuild.yml` publishes it, on pushes to `main`
that touch `.devcontainer/**` and on manual dispatch. Its path filter is exactly
Expand Down
Loading
Loading