Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 78 additions & 22 deletions cmd/wfctl/plugin_registry_sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -296,25 +296,30 @@ func releaseDownloads(ghRepo, tag string) ([]releaseAsset, error) {
}
var assets []releaseAsset
for _, a := range resp.Assets {
// Match goreleaser pattern: <name>-<os>-<arch>.tar.gz OR <name>_<os>_<arch>.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 (
Expand Down Expand Up @@ -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).
Expand All @@ -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
}
}
Comment thread
intel352 marked this conversation as resolved.

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).
Expand Down
102 changes: 102 additions & 0 deletions cmd/wfctl/plugin_registry_sync_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"), `{
Expand Down
Loading