From 91643e74d0c0b9848f27b1c106fd3d02a080628c Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Mon, 20 Apr 2026 15:41:19 -0400 Subject: [PATCH 1/2] fix(wfctl): pin plugin install to requested version, not stale manifest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When running 'wfctl plugin install myplugin@v0.2.1', the registry manifest may contain an older version (e.g. v0.1.0) with download URLs pointing at v0.1.0 assets. The requested version was being silently ignored, causing the old version to be installed. Fix: capture the requested version from the name@version argument, and when it differs from the manifest version, call pinManifestToVersion to rewrite download URLs in-place (replaces /releases/download/v0.1.0/ with /releases/download/v0.2.1/) and update manifest.Version. SHA256 checksums are cleared since they are only valid for the old assets. If the rewritten URL returns a 404, the error is wrapped with context: "requested version v0.2.1 not available for X (registry manifest is at v0.1.0)" — no silent fallback to the stale version. Root cause in BMW: app.yaml requires workflow-plugin-payments@v0.2.1 but the registry manifest had v0.1.0, so deploys kept installing v0.1.0. --- cmd/wfctl/plugin_install.go | 43 ++++- cmd/wfctl/plugin_version_pin_test.go | 242 +++++++++++++++++++++++++++ 2 files changed, 284 insertions(+), 1 deletion(-) create mode 100644 cmd/wfctl/plugin_version_pin_test.go diff --git a/cmd/wfctl/plugin_install.go b/cmd/wfctl/plugin_install.go index af77acaa..86152116 100644 --- a/cmd/wfctl/plugin_install.go +++ b/cmd/wfctl/plugin_install.go @@ -116,7 +116,7 @@ func runPluginInstall(args []string) error { } nameArg := fs.Arg(0) - rawName, _ := parseNameVersion(nameArg) + rawName, requestedVersion := parseNameVersion(nameArg) pluginName := normalizePluginName(rawName) cfg, err := LoadRegistryConfig(*cfgPath) @@ -165,6 +165,14 @@ func runPluginInstall(args []string) error { fmt.Fprintf(os.Stderr, "Found in registry %q.\n", sourceName) + // Pin the manifest to the requested version when it differs from what the registry has. + // The registry manifest may be stale (e.g. v0.1.0) while the user requests v0.2.1. + // pinManifestToVersion rewrites download URLs in-place so the right release is fetched. + registryVersion := manifest.Version + if requestedVersion != "" && requestedVersion != manifest.Version { + pinManifestToVersion(manifest, requestedVersion) + } + // Resolve and install dependencies before installing the plugin itself. if len(manifest.Dependencies) > 0 { resolved := make(map[string]string) @@ -174,6 +182,10 @@ func runPluginInstall(args []string) error { } if err := installPluginFromManifest(pluginDirVal, pluginName, manifest); err != nil { + if requestedVersion != "" && requestedVersion != registryVersion { + return fmt.Errorf("requested version %s not available for %q (registry manifest is at %s): %w", + requestedVersion, pluginName, registryVersion, err) + } return err } @@ -630,6 +642,35 @@ func installFromLocal(srcDir, pluginDir string) error { return nil } +// pinManifestToVersion rewrites the manifest's version and all download URLs to +// use requestedVersion. The registry manifest may lag behind the actual release +// (e.g. manifest says v0.1.0 but the user requests v0.2.1). GitHub release URLs +// follow a predictable pattern: replace /releases/download// with +// /releases/download//. SHA256 checksums are cleared since they are +// only valid for the original version's assets. +// +// If requestedVersion matches manifest.Version, this is a no-op. +func pinManifestToVersion(manifest *RegistryManifest, requestedVersion string) { + if requestedVersion == manifest.Version { + return + } + oldVersion := manifest.Version + manifest.Version = requestedVersion + for i := range manifest.Downloads { + url := manifest.Downloads[i].URL + // Replace the release tag in the GitHub releases download path. + rewritten := strings.ReplaceAll(url, + "/releases/download/"+oldVersion+"/", + "/releases/download/"+requestedVersion+"/") + // If the version string also appears in the filename, rewrite that too. + if rewritten == url && oldVersion != "" { + rewritten = strings.ReplaceAll(url, oldVersion, requestedVersion) + } + manifest.Downloads[i].URL = rewritten + manifest.Downloads[i].SHA256 = "" // checksums are for the old version's assets + } +} + // parseNameVersion splits "name@version" into (name, version). Version is empty if absent. func parseNameVersion(arg string) (name, ver string) { if idx := strings.Index(arg, "@"); idx >= 0 { diff --git a/cmd/wfctl/plugin_version_pin_test.go b/cmd/wfctl/plugin_version_pin_test.go new file mode 100644 index 00000000..a11c8829 --- /dev/null +++ b/cmd/wfctl/plugin_version_pin_test.go @@ -0,0 +1,242 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "strings" + "sync/atomic" + "testing" +) + +// TestPinManifestToVersion_URLRewritten verifies that pinManifestToVersion +// replaces the old version string in download URLs and updates manifest.Version. +func TestPinManifestToVersion_URLRewritten(t *testing.T) { + manifest := &RegistryManifest{ + Name: "payments", + Version: "v0.1.0", + Downloads: []PluginDownload{ + { + OS: "linux", + Arch: "amd64", + URL: "https://github.com/owner/repo/releases/download/v0.1.0/payments-linux-amd64.tar.gz", + SHA256: "abc123", + }, + { + OS: "darwin", + Arch: "arm64", + URL: "https://github.com/owner/repo/releases/download/v0.1.0/payments-darwin-arm64.tar.gz", + SHA256: "def456", + }, + }, + } + + pinManifestToVersion(manifest, "v0.2.1") + + if manifest.Version != "v0.2.1" { + t.Errorf("manifest.Version: got %q, want %q", manifest.Version, "v0.2.1") + } + for i, dl := range manifest.Downloads { + if !strings.Contains(dl.URL, "v0.2.1") { + t.Errorf("download[%d].URL: want v0.2.1 in %q", i, dl.URL) + } + if strings.Contains(dl.URL, "v0.1.0") { + t.Errorf("download[%d].URL: still contains old version v0.1.0 in %q", i, dl.URL) + } + if dl.SHA256 != "" { + t.Errorf("download[%d].SHA256: expected cleared after version pin, got %q", i, dl.SHA256) + } + } +} + +// TestPinManifestToVersion_SameVersion verifies that no URL rewriting happens +// when the requested version matches the manifest version. +func TestPinManifestToVersion_SameVersion(t *testing.T) { + origURL := "https://github.com/owner/repo/releases/download/v0.1.0/plugin.tar.gz" + manifest := &RegistryManifest{ + Name: "myplugin", + Version: "v0.1.0", + Downloads: []PluginDownload{ + {OS: "linux", Arch: "amd64", URL: origURL, SHA256: "abc"}, + }, + } + + pinManifestToVersion(manifest, "v0.1.0") + + if manifest.Downloads[0].URL != origURL { + t.Errorf("URL should not change when version matches: got %q", manifest.Downloads[0].URL) + } + if manifest.Downloads[0].SHA256 != "abc" { + t.Errorf("SHA256 should not be cleared when version matches: got %q", manifest.Downloads[0].SHA256) + } +} + +// TestRunPluginInstall_VersionPinHitsNewURL verifies that when name@vX.Y.Z is +// requested and the registry manifest has an older version, the installer +// rewrites download URLs to the requested version and successfully installs it. +func TestRunPluginInstall_VersionPinHitsNewURL(t *testing.T) { + const pluginName = "payments" + const oldVersion = "v0.1.0" + const newVersion = "v0.2.1" + + binaryContent := []byte("#!/bin/sh\necho payments\n") + newTarball := buildPluginTarGz(t, pluginName, binaryContent, minimalPluginJSON(pluginName, newVersion)) + + var hitNewVersion atomic.Int32 + var hitOldVersion atomic.Int32 + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/plugins/"+pluginName+"/manifest.json": + // Registry manifest with old version; download URL points to this server. + manifest := RegistryManifest{ + Name: pluginName, + Version: oldVersion, + Author: "tester", + Description: "test payments plugin", + Type: "external", + Tier: "community", + License: "MIT", + Downloads: []PluginDownload{ + { + OS: runtime.GOOS, + Arch: runtime.GOARCH, + URL: "http://" + r.Host + "/releases/download/" + oldVersion + "/" + pluginName + ".tar.gz", + }, + }, + } + data, _ := json.Marshal(manifest) + w.Header().Set("Content-Type", "application/json") + w.Write(data) //nolint:errcheck + case strings.Contains(r.URL.Path, newVersion): + hitNewVersion.Add(1) + w.WriteHeader(http.StatusOK) + w.Write(newTarball) //nolint:errcheck + case strings.Contains(r.URL.Path, oldVersion) && strings.Contains(r.URL.Path, "releases"): + hitOldVersion.Add(1) + http.NotFound(w, r) // old version doesn't exist at download server + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + // Write a registry config pointing at the test server. + cfgDir := t.TempDir() + regCfg := "registries:\n - name: test\n type: static\n url: " + srv.URL + "\n priority: 0\n" + regCfgPath := filepath.Join(cfgDir, "registry.yaml") + if err := os.WriteFile(regCfgPath, []byte(regCfg), 0600); err != nil { + t.Fatalf("write registry config: %v", err) + } + + // Run install in a temp cwd so .wfctl.yaml lockfile stays isolated. + origWD, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + cwdDir := t.TempDir() + if err := os.Chdir(cwdDir); err != nil { + t.Fatalf("chdir: %v", err) + } + t.Cleanup(func() { os.Chdir(origWD) }) //nolint:errcheck + + pluginsDir := t.TempDir() + err = runPluginInstall([]string{ + "--config", regCfgPath, + "--plugin-dir", pluginsDir, + pluginName + "@" + newVersion, + }) + if err != nil { + t.Fatalf("runPluginInstall: %v", err) + } + + // The new version URL must have been hit. + if hitNewVersion.Load() == 0 { + t.Error("expected request to new version URL, got none") + } + // The old version download URL must NOT have been hit (we switched to new). + if hitOldVersion.Load() > 0 { + t.Errorf("expected no request to old version download URL, but got %d", hitOldVersion.Load()) + } + + // Installed plugin.json should record the new version. + pjPath := filepath.Join(pluginsDir, pluginName, "plugin.json") + data, err := os.ReadFile(pjPath) + if err != nil { + t.Fatalf("read plugin.json: %v", err) + } + var pj installedPluginJSON + if err := json.Unmarshal(data, &pj); err != nil { + t.Fatalf("parse plugin.json: %v", err) + } + if pj.Version != newVersion { + t.Errorf("installed version: got %q, want %q", pj.Version, newVersion) + } +} + +// TestRunPluginInstall_VersionPinNotFound verifies that requesting a non-existent +// version returns an error and does not silently fall back to the registry version. +func TestRunPluginInstall_VersionPinNotFound(t *testing.T) { + const pluginName = "payments" + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/plugins/"+pluginName+"/manifest.json" { + manifest := RegistryManifest{ + Name: pluginName, + Version: "v0.1.0", + Author: "tester", + Description: "test payments plugin", + Type: "external", + Tier: "community", + License: "MIT", + Downloads: []PluginDownload{ + { + OS: runtime.GOOS, + Arch: runtime.GOARCH, + URL: "http://" + r.Host + "/releases/download/v0.1.0/" + pluginName + ".tar.gz", + }, + }, + } + data, _ := json.Marshal(manifest) + w.Header().Set("Content-Type", "application/json") + w.Write(data) //nolint:errcheck + return + } + // All download URLs return 404 (simulating version not found). + http.NotFound(w, r) + })) + defer srv.Close() + + cfgDir := t.TempDir() + regCfg := "registries:\n - name: test\n type: static\n url: " + srv.URL + "\n priority: 0\n" + regCfgPath := filepath.Join(cfgDir, "registry.yaml") + if err := os.WriteFile(regCfgPath, []byte(regCfg), 0600); err != nil { + t.Fatalf("write registry config: %v", err) + } + + origWD, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + if err := os.Chdir(t.TempDir()); err != nil { + t.Fatalf("chdir: %v", err) + } + t.Cleanup(func() { os.Chdir(origWD) }) //nolint:errcheck + + pluginsDir := t.TempDir() + err = runPluginInstall([]string{ + "--config", regCfgPath, + "--plugin-dir", pluginsDir, + pluginName + "@v99.99.99", + }) + if err == nil { + t.Fatal("expected error for non-existent version, got nil (should not silently fall back)") + } + // Error should mention the requested version. + if !strings.Contains(err.Error(), "v99.99.99") { + t.Errorf("error should mention requested version v99.99.99, got: %v", err) + } +} From 1b51a0b26eda0d0738e4a89800b24ddf8ccb3723 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Mon, 20 Apr 2026 15:42:47 -0400 Subject: [PATCH 2/2] test(wfctl): add integration test for no-version install using manifest version --- cmd/wfctl/plugin_version_pin_test.go | 88 ++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) diff --git a/cmd/wfctl/plugin_version_pin_test.go b/cmd/wfctl/plugin_version_pin_test.go index a11c8829..cd35ddd9 100644 --- a/cmd/wfctl/plugin_version_pin_test.go +++ b/cmd/wfctl/plugin_version_pin_test.go @@ -74,6 +74,94 @@ func TestPinManifestToVersion_SameVersion(t *testing.T) { } } +// TestRunPluginInstall_NoVersionUsesManifest verifies that when no @version suffix +// is given, the manifest version is used as-is (existing behavior unchanged). +func TestRunPluginInstall_NoVersionUsesManifest(t *testing.T) { + const pluginName = "payments" + const manifestVersion = "v0.1.0" + + binaryContent := []byte("#!/bin/sh\necho payments\n") + tarball := buildPluginTarGz(t, pluginName, binaryContent, minimalPluginJSON(pluginName, manifestVersion)) + + var hitManifestVersion atomic.Int32 + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/plugins/"+pluginName+"/manifest.json": + manifest := RegistryManifest{ + Name: pluginName, + Version: manifestVersion, + Author: "tester", + Description: "test payments plugin", + Type: "external", + Tier: "community", + License: "MIT", + Downloads: []PluginDownload{ + { + OS: runtime.GOOS, + Arch: runtime.GOARCH, + URL: "http://" + r.Host + "/releases/download/" + manifestVersion + "/" + pluginName + ".tar.gz", + }, + }, + } + data, _ := json.Marshal(manifest) + w.Header().Set("Content-Type", "application/json") + w.Write(data) //nolint:errcheck + case strings.Contains(r.URL.Path, manifestVersion): + hitManifestVersion.Add(1) + w.WriteHeader(http.StatusOK) + w.Write(tarball) //nolint:errcheck + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + cfgDir := t.TempDir() + regCfg := "registries:\n - name: test\n type: static\n url: " + srv.URL + "\n priority: 0\n" + regCfgPath := filepath.Join(cfgDir, "registry.yaml") + if err := os.WriteFile(regCfgPath, []byte(regCfg), 0600); err != nil { + t.Fatalf("write registry config: %v", err) + } + + origWD, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + if err := os.Chdir(t.TempDir()); err != nil { + t.Fatalf("chdir: %v", err) + } + t.Cleanup(func() { os.Chdir(origWD) }) //nolint:errcheck + + pluginsDir := t.TempDir() + // No @version suffix — should use manifest version unchanged. + if err := runPluginInstall([]string{ + "--config", regCfgPath, + "--plugin-dir", pluginsDir, + pluginName, // no @version + }); err != nil { + t.Fatalf("runPluginInstall (no version): %v", err) + } + + if hitManifestVersion.Load() == 0 { + t.Error("expected download from manifest version URL, got none") + } + + // Installed plugin.json should record the manifest version. + pjPath := filepath.Join(pluginsDir, pluginName, "plugin.json") + data, err := os.ReadFile(pjPath) + if err != nil { + t.Fatalf("read plugin.json: %v", err) + } + var pj installedPluginJSON + if err := json.Unmarshal(data, &pj); err != nil { + t.Fatalf("parse plugin.json: %v", err) + } + if pj.Version != manifestVersion { + t.Errorf("installed version: got %q, want %q", pj.Version, manifestVersion) + } +} + // TestRunPluginInstall_VersionPinHitsNewURL verifies that when name@vX.Y.Z is // requested and the registry manifest has an older version, the installer // rewrites download URLs to the requested version and successfully installs it.