From 2a943d29ef04fa90cf0da1370e01d55b819e6b59 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Wed, 3 Jun 2026 04:44:56 -0400 Subject: [PATCH 1/2] fix(wfctl): sync versioned registry assets --- cmd/wfctl/plugin_registry_sync.go | 61 ++++++++++++++-------- cmd/wfctl/plugin_registry_sync_test.go | 72 ++++++++++++++++++++++++++ 2 files changed, 111 insertions(+), 22 deletions(-) diff --git a/cmd/wfctl/plugin_registry_sync.go b/cmd/wfctl/plugin_registry_sync.go index 34331188d..0ae561a98 100644 --- a/cmd/wfctl/plugin_registry_sync.go +++ b/cmd/wfctl/plugin_registry_sync.go @@ -296,25 +296,30 @@ func releaseDownloads(ghRepo, tag string) ([]releaseAsset, error) { } var assets []releaseAsset for _, a := range resp.Assets { - // Match goreleaser pattern: --.tar.gz OR __.tar.gz - nameNoExt := strings.TrimSuffix(a.Name, ".tar.gz") - nameNoExt = strings.TrimSuffix(nameNoExt, ".tgz") - parts := strings.Split(nameNoExt, "-") + os, arch, ok := releaseAssetPlatform(a.Name) + if !ok { + continue + } + assets = append(assets, releaseAsset{Name: a.Name, OS: os, Arch: arch, URL: a.URL}) + } + return assets, nil +} + +func releaseAssetPlatform(assetName string) (string, string, bool) { + nameNoExt := strings.TrimSuffix(assetName, ".tar.gz") + nameNoExt = strings.TrimSuffix(nameNoExt, ".tgz") + for _, sep := range []string{"-", "_"} { + parts := strings.Split(nameNoExt, sep) if len(parts) < 3 { - parts = strings.Split(nameNoExt, "_") - if len(parts) < 3 { - continue - } + continue } os := parts[len(parts)-2] arch := parts[len(parts)-1] - // Sanity-check os/arch values - if !isKnownOS(os) || !isKnownArch(arch) { - continue + if isKnownOS(os) && isKnownArch(arch) { + return os, arch, true } - assets = append(assets, releaseAsset{Name: a.Name, OS: os, Arch: arch, URL: a.URL}) } - return assets, nil + return "", "", false } var ( @@ -525,15 +530,7 @@ func applyFix(manifestPath string, raw map[string]any, ghRepo, targetTag, target // workflow#703 — also sync capabilities + minEngineVersion + iacProvider // from the tagged plugin.json (source-of-truth in the upstream repo). if pluginJSON, _ := fetchPluginJSON(ghRepo, targetTag); pluginJSON != nil { - if caps, ok := pluginJSON["capabilities"]; ok && caps != nil { - raw["capabilities"] = caps - } - if mev, ok := pluginJSON["minEngineVersion"]; ok && mev != nil { - raw["minEngineVersion"] = mev - } - if iac, ok := pluginJSON["iacProvider"]; ok && iac != nil { - raw["iacProvider"] = iac - } + syncManifestMetadataFromPluginJSON(raw, pluginJSON) } // Marshal with 2-space indent + trailing newline (matches bash jq output). @@ -545,6 +542,26 @@ func applyFix(manifestPath string, raw map[string]any, ghRepo, targetTag, target return os.WriteFile(manifestPath, out, 0644) // #nosec G306 } +func syncManifestMetadataFromPluginJSON(raw, pluginJSON map[string]any) { + if caps, ok := pluginJSON["capabilities"]; ok && caps != nil { + raw["capabilities"] = caps + } + if services, ok := pluginJSON["iacServices"]; ok && services != nil { + caps, _ := raw["capabilities"].(map[string]any) + if caps == nil { + caps = map[string]any{} + raw["capabilities"] = caps + } + caps["serviceMethods"] = services + } + if mev, ok := pluginJSON["minEngineVersion"]; ok && mev != nil { + raw["minEngineVersion"] = mev + } + if iac, ok := pluginJSON["iacProvider"]; ok && iac != nil { + raw["iacProvider"] = iac + } +} + // fetchPluginJSON gets the tagged plugin.json from the upstream repo via the // GitHub Contents API. Returns nil on any failure (silent fallback per // bash behavior — plan C2 fix preserves this). diff --git a/cmd/wfctl/plugin_registry_sync_test.go b/cmd/wfctl/plugin_registry_sync_test.go index adbdc524f..ee7343553 100644 --- a/cmd/wfctl/plugin_registry_sync_test.go +++ b/cmd/wfctl/plugin_registry_sync_test.go @@ -108,6 +108,78 @@ func TestPluginRegistrySync_DownloadsMatchVersion(t *testing.T) { }) } +func TestPluginRegistrySync_ReleaseAssetPlatform(t *testing.T) { + cases := []struct { + name string + wantOS string + wantArch string + wantOK bool + }{ + { + name: "workflow-plugin-aws-linux-amd64.tar.gz", + wantOS: "linux", + wantArch: "amd64", + wantOK: true, + }, + { + name: "workflow-plugin-gcp_2.3.0_linux_amd64.tar.gz", + wantOS: "linux", + wantArch: "amd64", + wantOK: true, + }, + { + name: "checksums.txt", + wantOK: false, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + gotOS, gotArch, gotOK := releaseAssetPlatform(tc.name) + if gotOK != tc.wantOK { + t.Fatalf("ok = %v, want %v", gotOK, tc.wantOK) + } + if gotOS != tc.wantOS || gotArch != tc.wantArch { + t.Fatalf("platform = %s/%s, want %s/%s", gotOS, gotArch, tc.wantOS, tc.wantArch) + } + }) + } +} + +func TestPluginRegistrySync_MetadataSyncProjectsIaCServices(t *testing.T) { + raw := map[string]any{ + "capabilities": map[string]any{ + "moduleTypes": []any{"old"}, + }, + } + pluginJSON := map[string]any{ + "capabilities": map[string]any{ + "moduleTypes": []any{"iac.provider"}, + }, + "iacServices": []any{ + "workflow.plugin.external.iac.IaCProviderRequired", + "workflow.plugin.external.iac.IaCProviderRunner", + }, + "minEngineVersion": "0.73.0", + } + + syncManifestMetadataFromPluginJSON(raw, pluginJSON) + + caps, ok := raw["capabilities"].(map[string]any) + if !ok { + t.Fatalf("capabilities type = %T", raw["capabilities"]) + } + serviceMethods, ok := caps["serviceMethods"].([]any) + if !ok { + t.Fatalf("serviceMethods type = %T", caps["serviceMethods"]) + } + if len(serviceMethods) != 2 || serviceMethods[1] != "workflow.plugin.external.iac.IaCProviderRunner" { + t.Fatalf("serviceMethods = %#v", serviceMethods) + } + if got := raw["minEngineVersion"]; got != "0.73.0" { + t.Fatalf("minEngineVersion = %v, want 0.73.0", got) + } +} + func TestPluginRegistrySync_DefaultSkipsBuiltinManifests(t *testing.T) { registry := t.TempDir() mustWrite(t, filepath.Join(registry, "plugins", "admincore", "manifest.json"), `{ From 5044b14b2dcd5bd23e5f3aad9b4e6802ebff5311 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Wed, 3 Jun 2026 05:01:41 -0400 Subject: [PATCH 2/2] fix(wfctl): preserve registry sync capabilities --- cmd/wfctl/plugin_registry_sync.go | 51 +++++++++++++++++++++++--- cmd/wfctl/plugin_registry_sync_test.go | 42 ++++++++++++++++++--- 2 files changed, 81 insertions(+), 12 deletions(-) diff --git a/cmd/wfctl/plugin_registry_sync.go b/cmd/wfctl/plugin_registry_sync.go index 0ae561a98..0d38d61d9 100644 --- a/cmd/wfctl/plugin_registry_sync.go +++ b/cmd/wfctl/plugin_registry_sync.go @@ -296,11 +296,11 @@ func releaseDownloads(ghRepo, tag string) ([]releaseAsset, error) { } var assets []releaseAsset for _, a := range resp.Assets { - os, arch, ok := releaseAssetPlatform(a.Name) + goos, goarch, ok := releaseAssetPlatform(a.Name) if !ok { continue } - assets = append(assets, releaseAsset{Name: a.Name, OS: os, Arch: arch, URL: a.URL}) + assets = append(assets, releaseAsset{Name: a.Name, OS: goos, Arch: goarch, URL: a.URL}) } return assets, nil } @@ -543,16 +543,25 @@ func applyFix(manifestPath string, raw map[string]any, ghRepo, targetTag, target } func syncManifestMetadataFromPluginJSON(raw, pluginJSON map[string]any) { + var caps map[string]any if caps, ok := pluginJSON["capabilities"]; ok && caps != nil { - raw["capabilities"] = caps + if capsObj, ok := caps.(map[string]any); ok { + raw["capabilities"] = capsObj + } + } + caps, _ = raw["capabilities"].(map[string]any) + + services := registrySyncStringSliceFromAny(pluginJSON["iacServices"]) + if nestedServices := registrySyncStringSliceFromAny(caps["iacServices"]); len(nestedServices) > 0 { + services = appendUniqueStrings(services, nestedServices...) + delete(caps, "iacServices") } - if services, ok := pluginJSON["iacServices"]; ok && services != nil { - caps, _ := raw["capabilities"].(map[string]any) + if len(services) > 0 { if caps == nil { caps = map[string]any{} raw["capabilities"] = caps } - caps["serviceMethods"] = services + caps["serviceMethods"] = appendUniqueStrings(registrySyncStringSliceFromAny(caps["serviceMethods"]), services...) } if mev, ok := pluginJSON["minEngineVersion"]; ok && mev != nil { raw["minEngineVersion"] = mev @@ -562,6 +571,36 @@ func syncManifestMetadataFromPluginJSON(raw, pluginJSON map[string]any) { } } +func registrySyncStringSliceFromAny(v any) []string { + switch values := v.(type) { + case []string: + return values + case []any: + var out []string + for _, value := range values { + if s, ok := value.(string); ok && s != "" { + out = append(out, s) + } + } + return out + default: + return nil + } +} + +func appendUniqueStrings(base []string, values ...string) []string { + seen := make(map[string]bool, len(base)+len(values)) + var out []string + for _, value := range append(base, values...) { + if value == "" || seen[value] { + continue + } + seen[value] = true + out = append(out, value) + } + return out +} + // fetchPluginJSON gets the tagged plugin.json from the upstream repo via the // GitHub Contents API. Returns nil on any failure (silent fallback per // bash behavior — plan C2 fix preserves this). diff --git a/cmd/wfctl/plugin_registry_sync_test.go b/cmd/wfctl/plugin_registry_sync_test.go index ee7343553..2687d4024 100644 --- a/cmd/wfctl/plugin_registry_sync_test.go +++ b/cmd/wfctl/plugin_registry_sync_test.go @@ -149,11 +149,14 @@ func TestPluginRegistrySync_MetadataSyncProjectsIaCServices(t *testing.T) { raw := map[string]any{ "capabilities": map[string]any{ "moduleTypes": []any{"old"}, + "serviceMethods": []any{ + "existing.service/Call", + }, }, } pluginJSON := map[string]any{ - "capabilities": map[string]any{ - "moduleTypes": []any{"iac.provider"}, + "capabilities": []any{ + map[string]any{"name": "canonical-capability", "role": "provider"}, }, "iacServices": []any{ "workflow.plugin.external.iac.IaCProviderRequired", @@ -168,11 +171,11 @@ func TestPluginRegistrySync_MetadataSyncProjectsIaCServices(t *testing.T) { if !ok { t.Fatalf("capabilities type = %T", raw["capabilities"]) } - serviceMethods, ok := caps["serviceMethods"].([]any) - if !ok { - t.Fatalf("serviceMethods type = %T", caps["serviceMethods"]) + if moduleTypes := caps["moduleTypes"].([]any); len(moduleTypes) != 1 || moduleTypes[0] != "old" { + t.Fatalf("canonical capabilities array should not replace registry capabilities object: %#v", caps) } - if len(serviceMethods) != 2 || serviceMethods[1] != "workflow.plugin.external.iac.IaCProviderRunner" { + serviceMethods := registrySyncStringSliceFromAny(caps["serviceMethods"]) + if len(serviceMethods) != 3 || serviceMethods[0] != "existing.service/Call" || serviceMethods[2] != "workflow.plugin.external.iac.IaCProviderRunner" { t.Fatalf("serviceMethods = %#v", serviceMethods) } if got := raw["minEngineVersion"]; got != "0.73.0" { @@ -180,6 +183,33 @@ func TestPluginRegistrySync_MetadataSyncProjectsIaCServices(t *testing.T) { } } +func TestPluginRegistrySync_MetadataSyncProjectsNestedIaCServices(t *testing.T) { + raw := map[string]any{} + pluginJSON := map[string]any{ + "capabilities": map[string]any{ + "moduleTypes": []any{"iac.provider"}, + "iacServices": []any{ + "workflow.plugin.external.iac.IaCProviderRequired", + "workflow.plugin.external.iac.IaCProviderRunner", + }, + }, + } + + syncManifestMetadataFromPluginJSON(raw, pluginJSON) + + caps, ok := raw["capabilities"].(map[string]any) + if !ok { + t.Fatalf("capabilities type = %T", raw["capabilities"]) + } + if _, ok := caps["iacServices"]; ok { + t.Fatalf("registry capabilities should not retain schema-unknown iacServices: %#v", caps) + } + serviceMethods := registrySyncStringSliceFromAny(caps["serviceMethods"]) + if len(serviceMethods) != 2 || serviceMethods[1] != "workflow.plugin.external.iac.IaCProviderRunner" { + t.Fatalf("serviceMethods = %#v", serviceMethods) + } +} + func TestPluginRegistrySync_DefaultSkipsBuiltinManifests(t *testing.T) { registry := t.TempDir() mustWrite(t, filepath.Join(registry, "plugins", "admincore", "manifest.json"), `{