diff --git a/cmd/wfctl/plugin_registry_sync.go b/cmd/wfctl/plugin_registry_sync.go index 34331188..0d38d61d 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, "-") + goos, goarch, ok := releaseAssetPlatform(a.Name) + if !ok { + continue + } + assets = append(assets, releaseAsset{Name: a.Name, OS: goos, Arch: goarch, 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,65 @@ 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) { + var caps map[string]any + if caps, ok := pluginJSON["capabilities"]; ok && caps != nil { + 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 len(services) > 0 { + if caps == nil { + caps = map[string]any{} + raw["capabilities"] = caps + } + caps["serviceMethods"] = appendUniqueStrings(registrySyncStringSliceFromAny(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 + } +} + +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 adbdc524..2687d402 100644 --- a/cmd/wfctl/plugin_registry_sync_test.go +++ b/cmd/wfctl/plugin_registry_sync_test.go @@ -108,6 +108,108 @@ 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"}, + "serviceMethods": []any{ + "existing.service/Call", + }, + }, + } + pluginJSON := map[string]any{ + "capabilities": []any{ + map[string]any{"name": "canonical-capability", "role": "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"]) + } + if moduleTypes := caps["moduleTypes"].([]any); len(moduleTypes) != 1 || moduleTypes[0] != "old" { + t.Fatalf("canonical capabilities array should not replace registry capabilities object: %#v", caps) + } + 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" { + t.Fatalf("minEngineVersion = %v, want 0.73.0", got) + } +} + +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"), `{