From 136fbf2716d2399d65566c1c9ba0c297a6444959 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 14 Jun 2026 14:14:06 +0000 Subject: [PATCH 1/3] fix(plugin): regenerate local plugin create_files when their content changes Local plugins' files in the virtenv were never recreated after editing the source files referenced by create_files. Devbox decides whether to recompute the environment (and rewrite plugin files) by comparing a state hash that is derived, in part, from each included plugin config's hash. That hash only covered plugin.json, not the content files that create_files references, so editing a local plugin's source file left the state hash unchanged and the stale virtenv copy in place. Override Config.Hash() to fold the contents of create_files source files into the hash for local plugins. Built-in plugin files are embedded in the binary (covered by the Devbox version) and remote plugins are addressed by an immutable ref, so those are left to the config-file hash alone. Fixes #2755 --- internal/plugin/local_test.go | 102 ++++++++++++++++++++++++++++++++++ internal/plugin/plugin.go | 48 ++++++++++++++++ 2 files changed, 150 insertions(+) create mode 100644 internal/plugin/local_test.go diff --git a/internal/plugin/local_test.go b/internal/plugin/local_test.go new file mode 100644 index 00000000000..bcc04550009 --- /dev/null +++ b/internal/plugin/local_test.go @@ -0,0 +1,102 @@ +package plugin + +import ( + "os" + "path/filepath" + "testing" + + "go.jetify.com/devbox/nix/flake" +) + +// newTestLocalPlugin writes a plugin.json (with the given create_files content +// file) into a temp dir and returns a *LocalPlugin pointing at it along with the +// path of the referenced content file. +func newTestLocalPlugin(t *testing.T, fileContents string) (*LocalPlugin, string) { + t.Helper() + pluginDir := t.TempDir() + + pluginJSON := `{ + "name": "testplugin", + "create_files": { + "{{ .Virtenv }}/test.txt": "test.txt" + } + }` + if err := os.WriteFile(filepath.Join(pluginDir, pluginConfigName), []byte(pluginJSON), 0o644); err != nil { + t.Fatal(err) + } + + contentPath := filepath.Join(pluginDir, "test.txt") + if err := os.WriteFile(contentPath, []byte(fileContents), 0o644); err != nil { + t.Fatal(err) + } + + local, err := newLocalPlugin(flake.Ref{Type: flake.TypePath, Path: pluginDir}, pluginDir) + if err != nil { + t.Fatal(err) + } + return local, contentPath +} + +// TestLocalPluginHashIncludesCreateFilesContent verifies that editing a file +// referenced by a local plugin's create_files changes the plugin config hash, +// even though devbox.json and the plugin.json are unchanged. This is what allows +// Devbox to detect the change and regenerate the file in the virtenv. +// See https://github.com/jetify-com/devbox/issues/2755. +func TestLocalPluginHashIncludesCreateFilesContent(t *testing.T) { + local, contentPath := newTestLocalPlugin(t, "123") + + pluginJSON, err := local.Fetch() + if err != nil { + t.Fatal(err) + } + cfg, err := buildConfig(local, filepath.Dir(contentPath), string(pluginJSON)) + if err != nil { + t.Fatal(err) + } + + before, err := cfg.Hash() + if err != nil { + t.Fatal(err) + } + + // Edit the referenced content file without touching plugin.json. + if err := os.WriteFile(contentPath, []byte("456"), 0o644); err != nil { + t.Fatal(err) + } + + after, err := cfg.Hash() + if err != nil { + t.Fatal(err) + } + + if before == after { + t.Errorf("hash did not change after editing create_files content: %q", before) + } +} + +// TestLocalPluginHashStableWithoutContentChange verifies the hash is stable when +// the referenced files don't change. +func TestLocalPluginHashStableWithoutContentChange(t *testing.T) { + local, contentPath := newTestLocalPlugin(t, "123") + + pluginJSON, err := local.Fetch() + if err != nil { + t.Fatal(err) + } + cfg, err := buildConfig(local, filepath.Dir(contentPath), string(pluginJSON)) + if err != nil { + t.Fatal(err) + } + + first, err := cfg.Hash() + if err != nil { + t.Fatal(err) + } + second, err := cfg.Hash() + if err != nil { + t.Fatal(err) + } + if first != second { + t.Errorf("hash is not stable: %q != %q", first, second) + } +} diff --git a/internal/plugin/plugin.go b/internal/plugin/plugin.go index cf2834559e8..164fd76f80e 100644 --- a/internal/plugin/plugin.go +++ b/internal/plugin/plugin.go @@ -17,6 +17,7 @@ import ( "github.com/pkg/errors" "github.com/tailscale/hujson" + "go.jetify.com/devbox/internal/cachehash" "go.jetify.com/devbox/internal/devconfig/configfile" "go.jetify.com/devbox/internal/devpkg" "go.jetify.com/devbox/internal/lock" @@ -70,6 +71,53 @@ func (c *Config) Services() (services.Services, error) { return nil, nil } +// Hash returns a hash of the plugin config. It shadows the embedded +// configfile.ConfigFile.Hash() so that, for local plugins, the contents of the +// files referenced by create_files are folded into the hash. +// +// Devbox uses this hash (via Devbox.ConfigHash) to decide whether the cached +// environment is still up to date. Local plugin files can be edited without any +// corresponding change to devbox.json or the lockfile, so without this a local +// plugin's create_files would never be regenerated in the virtenv after the +// source files change. See https://github.com/jetify-com/devbox/issues/2755. +// +// Built-in plugin files are embedded in the Devbox binary (covered by the +// Devbox version in the state hash) and remote plugins are addressed by an +// immutable ref, so for those the config hash alone is sufficient. +func (c *Config) Hash() (string, error) { + configHash, err := c.ConfigFile.Hash() + if err != nil { + return "", err + } + + local, ok := c.Source.(*LocalPlugin) + if !ok { + return configHash, nil + } + + buf := bytes.Buffer{} + buf.WriteString(configHash) + + // Sort the content paths so the resulting hash is deterministic. + contentPaths := make([]string, 0, len(c.CreateFiles)) + for _, contentPath := range c.CreateFiles { + if contentPath != "" { + contentPaths = append(contentPaths, contentPath) + } + } + slices.Sort(contentPaths) + + for _, contentPath := range contentPaths { + content, err := local.FileContent(contentPath) + if err != nil { + return "", errors.WithStack(err) + } + buf.Write(content) + } + + return cachehash.Bytes(buf.Bytes()), nil +} + func (m *Manager) CreateFilesForConfig(cfg *Config) error { virtenvPath := filepath.Join(m.ProjectDir(), VirtenvPath) pkg := cfg.Source From 960dff971c4cb687e6296cec07b7f07856aa3be7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 14 Jun 2026 14:24:39 +0000 Subject: [PATCH 2/3] fix(plugin): hash plugin-only fields and per-file content for local plugins Address review feedback on the local plugin cache-invalidation fix: - configfile.ConfigFile.Hash() only covers ConfigFile fields, so the previous seed omitted plugin-only fields (create_files destinations, version, readme, __remove_trigger_package). Fold these into the hash explicitly so changing them also invalidates Devbox's cached state. - Hash each create_files source file independently, keyed by its path, instead of concatenating raw bytes. Concatenation is ambiguous (different file/content splits can yield identical byte streams) and buffered every file at once. --- internal/plugin/local_test.go | 33 ++++++++++++++++++++++ internal/plugin/plugin.go | 52 ++++++++++++++++++++++------------- 2 files changed, 66 insertions(+), 19 deletions(-) diff --git a/internal/plugin/local_test.go b/internal/plugin/local_test.go index bcc04550009..7038a1d13d2 100644 --- a/internal/plugin/local_test.go +++ b/internal/plugin/local_test.go @@ -74,6 +74,39 @@ func TestLocalPluginHashIncludesCreateFilesContent(t *testing.T) { } } +// TestLocalPluginHashIncludesCreateFilesDestinations verifies that changing a +// create_files destination (a plugin-only field not covered by +// configfile.ConfigFile.Hash()) changes the plugin config hash. +func TestLocalPluginHashIncludesCreateFilesDestinations(t *testing.T) { + local, contentPath := newTestLocalPlugin(t, "123") + + pluginJSON, err := local.Fetch() + if err != nil { + t.Fatal(err) + } + cfg, err := buildConfig(local, filepath.Dir(contentPath), string(pluginJSON)) + if err != nil { + t.Fatal(err) + } + + before, err := cfg.Hash() + if err != nil { + t.Fatal(err) + } + + // Point the same source file at a different destination in the virtenv. + cfg.CreateFiles = map[string]string{"renamed.txt": "test.txt"} + + after, err := cfg.Hash() + if err != nil { + t.Fatal(err) + } + + if before == after { + t.Errorf("hash did not change after changing create_files destination: %q", before) + } +} + // TestLocalPluginHashStableWithoutContentChange verifies the hash is stable when // the referenced files don't change. func TestLocalPluginHashStableWithoutContentChange(t *testing.T) { diff --git a/internal/plugin/plugin.go b/internal/plugin/plugin.go index 164fd76f80e..28b3c1f44b5 100644 --- a/internal/plugin/plugin.go +++ b/internal/plugin/plugin.go @@ -72,14 +72,18 @@ func (c *Config) Services() (services.Services, error) { } // Hash returns a hash of the plugin config. It shadows the embedded -// configfile.ConfigFile.Hash() so that, for local plugins, the contents of the -// files referenced by create_files are folded into the hash. +// configfile.ConfigFile.Hash() so that, for local plugins, the plugin-only +// fields and the contents of the files referenced by create_files are folded +// into the hash. // // Devbox uses this hash (via Devbox.ConfigHash) to decide whether the cached -// environment is still up to date. Local plugin files can be edited without any -// corresponding change to devbox.json or the lockfile, so without this a local -// plugin's create_files would never be regenerated in the virtenv after the -// source files change. See https://github.com/jetify-com/devbox/issues/2755. +// environment is still up to date. configfile.ConfigFile.Hash() only hashes the +// ConfigFile fields, so on its own it omits plugin-only fields (create_files, +// version, ...) and the create_files content files. For a local plugin all of +// these live on disk and can be edited without any corresponding change to +// devbox.json or the lockfile, so without this a local plugin's create_files +// would never be regenerated in the virtenv after the source changes. See +// https://github.com/jetify-com/devbox/issues/2755. // // Built-in plugin files are embedded in the Devbox binary (covered by the // Devbox version in the state hash) and remote plugins are addressed by an @@ -95,27 +99,37 @@ func (c *Config) Hash() (string, error) { return configHash, nil } - buf := bytes.Buffer{} - buf.WriteString(configHash) - - // Sort the content paths so the resulting hash is deterministic. - contentPaths := make([]string, 0, len(c.CreateFiles)) + // Hash each referenced file independently, keyed by its source path, rather + // than concatenating raw bytes. Concatenation is ambiguous (different + // file/content splits can produce identical byte streams) and would buffer + // every file at once. + fileHashes := make(map[string]string, len(c.CreateFiles)) for _, contentPath := range c.CreateFiles { - if contentPath != "" { - contentPaths = append(contentPaths, contentPath) + if contentPath == "" { + continue } - } - slices.Sort(contentPaths) - - for _, contentPath := range contentPaths { content, err := local.FileContent(contentPath) if err != nil { return "", errors.WithStack(err) } - buf.Write(content) + fileHashes[contentPath] = cachehash.Bytes(content) } - return cachehash.Bytes(buf.Bytes()), nil + return cachehash.JSON(struct { + ConfigHash string `json:"config_hash"` + CreateFiles map[string]string `json:"create_files"` + Version string `json:"version"` + Readme string `json:"readme"` + RemoveTriggerPackage bool `json:"remove_trigger_package"` + FileHashes map[string]string `json:"file_hashes"` + }{ + ConfigHash: configHash, + CreateFiles: c.CreateFiles, + Version: c.Version, + Readme: c.DeprecatedDescription, + RemoveTriggerPackage: c.RemoveTriggerPackage, + FileHashes: fileHashes, + }) } func (m *Manager) CreateFilesForConfig(cfg *Config) error { From 07682de611c2c0dbc14ff90266cb1ad4b163d904 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 14 Jun 2026 14:38:29 +0000 Subject: [PATCH 3/3] ci: re-trigger CI (flaky external-flake fetch in add_platforms_flakeref.test)