Skip to content

fix: the prebuild tag moved for files the image cannot see - #590

Merged
blooop merged 1 commit into
mainfrom
fix/prebuild-context-hashes-only-what-reaches-the-image
Sep 9, 2026
Merged

fix: the prebuild tag moved for files the image cannot see#590
blooop merged 1 commit into
mainfrom
fix/prebuild-context-hashes-only-what-reaches-the-image

Conversation

@blooop

@blooop blooop commented Sep 9, 2026

Copy link
Copy Markdown
Owner

Opening this repository with dl pulls a published container instead of building one, and it had gone back to building.

What was wrong

The prebuild mechanism itself is fine. Running devpod's own hash-and-lookup against main at 4db3427 computes devpod-5bf7be3e3e7e1b3f4fbb01a9b3ab88e7 and finds it in GHCR. Package public, workflow green, local and CI hashes identical on several separate trees.

What was wrong is what the tag is allowed to notice. build.context is .devcontainer, and a Dockerfile with no COPY leaves devpod's includeFiles empty — at which point it hashes the whole context directory, keyed by path and CRC32, with only its own feature-staging folder excluded. There was no .dockerignore inside .devcontainer/, so the context half of the hash was "every byte in this directory". Four things in there cannot reach the image:

  • claude-code/README.md and claude-code/TROUBLESHOOTING.md — prose, present because the Features spec wants it beside its feature
  • claude-code/init-host.sh — the initializeCommand, which runs on the host. normalizeConfigForHash already clears DevContainerActions out of the config half of the hash for exactly that reason; the script was then hashed straight back in as context
  • devcontainer.json — devpod read it to get here and never reads it out of the context, and the config half already carries every build input it declares. This manifest is mostly comments, and abf6d9b moved the tag with ~30 lines of them beside a one-line mount change

Measured on devpod 0.26.1, amd64, at 4db3427: appending one comment line to claude-code/README.md and one to claude-code/init-host.sh moves the tag from devpod-5bf7be3e3e7e1b3f4fbb01a9b3ab88e7, which CI has published and every launch pulls, to devpod-90e9641b9d3661f681e640eeedb3a410, which nothing has ever built. Several minutes per launch, on every branch carrying the edit — and the only symptom is that opening a container is slow again, because devpod treats an unmatched tag as a cache miss and builds locally without complaint.

#588 is a worked example: of its four .devcontainer/ files, three were prose or the host hook.

The fix

.devcontainer/.dockerignore narrows the context half of the hash to the files the image is actually built from.

Excluding init-host.sh is what makes the two halves of the hash agree rather than a new judgement call. The manifest goes the same way and for the same reason.

Checked in both directions, because an exclusion too wide is the worse bug — a launch that pulls an image built from a Dockerfile the branch no longer has comes up fine and is the wrong container. Re-measured on this branch's tree:

edit tag moves?
feature README.md / TROUBLESHOOTING.md no
claude-code/init-host.sh no
devcontainer.json, comment-only no
claude-code/install.sh yes
claude-code/devcontainer-feature.json yes
build.cacheFrom changed yes
a feature added yes

The resulting image was then diffed against one built from the same tree without the .dockerignore: same 19 layers, same env, same metadata labels, claude and dockerd both present. The local feature still resolves from the filesystem, not from the context tar.

Guards

Five, four of them because the premise can move under this file.

test_the_dockerfile_copies_nothing_out_of_the_build_context is the one to read first — it is the claim the exclusions are downstream of. Add a COPY and the excluded files stop being invisible to the image and start being missing from it.

test_every_file_in_the_build_context_is_classified_as_a_build_input_or_not makes a new file under .devcontainer/ a failure until somebody says which side of the line it is on, because the silent default is the expensive one.

Each was mutation-tested to fail for the right reason: dropping **/*.md, over-excluding install.sh, adding an unclassified file, adding a COPY, and writing a glob form the matcher cannot evaluate.

One rebuild is owed

The .dockerignore is itself part of the context, so this moves the tag once. Launches build locally until devcontainer-prebuild.yml republishes on main.

Not covered

devpod's hashing here is the blooop/devpod fork's, and it differs from loft-sh's in a way that matters: loft-sh skips every file when includeFiles is empty and returns an empty context hash, so under that build claude-code/install.sh could change without moving the tag, CI would find the tag already published, and a stale image would be served indefinitely. The fork errs the other way, which is slow rather than wrong, and devsy-org/devsy — the live continuation — keeps the fork's semantics. Nothing here guards against running a build that does not, and a guard for it would need a real devpod, so it belongs in the e2e suite.

🤖 Generated with Claude Code

@sourcery-ai

sourcery-ai Bot commented Sep 9, 2026

Copy link
Copy Markdown

Reviewer's Guide

The PR fixes unnecessary devcontainer prebuild cache misses by adding a context .dockerignore that excludes files the image cannot see while retaining all actual image build inputs, adds tests to protect both sides of that boundary, and documents the hashing and workflow implications.

Flow diagram for devcontainer prebuild hashing

flowchart LR
    Files[".devcontainer files"] --> Ignore[".dockerignore"]
    Ignore --> Context["Image build context"]
    Config["Parsed devcontainer config"] --> Hash["Devpod prebuild hash"]
    Context --> Hash
    Hash --> Lookup["GHCR tag lookup"]
    Lookup -->|found| Pull["Pull published image"]
    Lookup -->|missing| Build["Build locally"]
Loading

File-Level Changes

Change Details Files
Narrow the prebuild hash context to files that can affect the image.
  • Add .dockerignore patterns excluding documentation, the host initialization script, and the manifest from the context hash.
  • Retain Dockerfile and local feature implementation files as hashed build inputs.
  • Update manifest comments to document the image-visible boundary and workflow path-filtering rationale.
.devcontainer/.dockerignore
.devcontainer/devcontainer.json
Add regression guards for the context classification and hashing boundary.
  • Assert the Dockerfile does not read context files via COPY or ADD.
  • Require every context file to be explicitly classified as image input or non-input.
  • Verify excluded files do not move the tag while build inputs remain included.
  • Verify manifest fields are accounted for by devpod's config hashing or create-time application.
test/unit/test_devcontainer_manifest.py
Document the corrected prebuild behavior and preserve safe workflow triggering.
  • Keep the workflow trigger as a safe superset of possible hashed inputs rather than duplicating .dockerignore patterns.
  • Document the one-time rebuild required after .dockerignore changes.
  • Update development documentation and the changelog with the hashing behavior, validated boundaries, and operational consequences.
.github/workflows/devcontainer-prebuild.yml
docs/development.md
CHANGELOG.md

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've reviewed your changes and they look great!


Sourcery is free for open source - if you like our reviews please consider sharing them ✨

Opening this repository with `dl` pulls a published container instead of
building one, and it had gone back to building.

The prebuild tag is a hash of the build context as well as of the build
config, and a Dockerfile with no COPY leaves devpod's `includeFiles` empty --
at which point it hashes the *whole* context directory, keyed by path and
CRC32, with only its own feature-staging folder excluded
(pkg/util/hash.DirectoryHash, from config.CalculatePrebuildHash). There was no
`.dockerignore` inside `.devcontainer/`, so the context half of the hash was
"every byte in this directory". Four things in there cannot reach the image:
the feature's README, its troubleshooting guide, the host-side
`initializeCommand`, and this manifest's own comments -- and the manifest is
mostly comments.

Measured on devpod 0.26.1, amd64, 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 and every
launch pulled, to devpod-90e9641b9d3661f681e640eeedb3a410, which nothing had
ever built. Several minutes per launch, on every branch carrying the edit, and
the symptom is only that opening a container is slow again -- devpod treats an
unmatched tag as a cache miss and builds locally without complaint.

`.devcontainer/.dockerignore` narrows the hash to the files the image is built
from. Excluding `init-host.sh` is what makes the two halves of the hash agree
rather than a new judgement call: `normalizeConfigForHash` already clears
`DevContainerActions`, where `initializeCommand` lives, out of the config half,
and the script was then hashed straight back in as context. The manifest goes
the same way and for the same reason -- devpod read it to get here and does not
read it out of the context, and the config half already carries every build
input it declares.

Checked in both directions, because an exclusion too wide is the worse bug: a
launch that pulls an image built from a Dockerfile the branch no longer has
comes up fine and is the wrong container. Prose, the host hook and
comment-only manifest edits no longer move the tag; the Dockerfile,
`claude-code/install.sh`, `claude-code/devcontainer-feature.json`, a changed
`build.cacheFrom` and an added feature all still do. The resulting image was
diffed against one built from the same tree without the `.dockerignore`: same
19 layers, same env, same metadata labels, `claude` and `dockerd` both present.

Five guards, four of them because the premise can move under this file.
test_the_dockerfile_copies_nothing_out_of_the_build_context is the first one to
read -- add a COPY and the excluded files stop being invisible to the image and
start being missing from it. The classification test makes a new file under
`.devcontainer/` a failure until somebody says which side of the line it is on,
since the silent default is the expensive one.

One rebuild is owed: the `.dockerignore` is itself part of the context, so this
commit moves the tag once, and launches build locally until
devcontainer-prebuild.yml republishes on main.
@blooop
blooop force-pushed the fix/prebuild-context-hashes-only-what-reaches-the-image branch from 40a5b7f to cf1675a Compare September 9, 2026 10:58
@blooop
blooop merged commit 908a24e into main Sep 9, 2026
15 checks passed
@blooop
blooop deleted the fix/prebuild-context-hashes-only-what-reaches-the-image branch September 9, 2026 11:30
@blooop blooop mentioned this pull request Sep 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant