From 8394884709b9d5330f7bf692041ce0d6e7c06f60 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Mon, 1 Jun 2026 20:41:31 -0400 Subject: [PATCH 1/2] feat(wfctl): natively sync registry core and readme --- cmd/wfctl/plugin_registry_sync.go | 2 +- cmd/wfctl/plugin_registry_sync_core.go | 376 +++++++++++++++++++++-- cmd/wfctl/plugin_registry_sync_readme.go | 253 +++++++++++++-- cmd/wfctl/plugin_registry_sync_test.go | 108 +++++++ docs/PLUGIN_RELEASE_GATES.md | 10 +- 5 files changed, 692 insertions(+), 57 deletions(-) diff --git a/cmd/wfctl/plugin_registry_sync.go b/cmd/wfctl/plugin_registry_sync.go index 7fdb6f0b..5dff6ff3 100644 --- a/cmd/wfctl/plugin_registry_sync.go +++ b/cmd/wfctl/plugin_registry_sync.go @@ -43,7 +43,7 @@ func runPluginRegistrySync(args []string) error { fs := flag.NewFlagSet("plugin registry-sync", flag.ContinueOnError) fix := fs.Bool("fix", false, "Apply changes (default: dry-run)") pluginFilter := fs.String("plugin", "", "Restrict to single plugin directory name") - verifyCaps := fs.Bool("verify-capabilities", false, "Spawn binary + diff capabilities (registry-side; slow; not yet implemented)") + verifyCaps := fs.Bool("verify-capabilities", false, "Spawn binary + diff capabilities (registry-side; slow)") registryDir := fs.String("registry-dir", ".", "Path to a workflow-registry checkout") fs.Usage = func() { fmt.Fprintf(fs.Output(), `Usage: wfctl plugin registry-sync [options] diff --git a/cmd/wfctl/plugin_registry_sync_core.go b/cmd/wfctl/plugin_registry_sync_core.go index d812e08a..fa6031d6 100644 --- a/cmd/wfctl/plugin_registry_sync_core.go +++ b/cmd/wfctl/plugin_registry_sync_core.go @@ -1,27 +1,22 @@ package main import ( + "encoding/json" "flag" "fmt" + "io" "os" "os/exec" "path/filepath" + "reflect" + "sort" + "strings" ) // runPluginRegistrySyncCore ports workflow-registry/scripts/sync-core-manifests.sh -// (workflow#762 Layer a). Compiles + runs an inspect program against a +// (workflow#762). Compiles + runs an inspect program against a // workflow checkout to discover the canonical core-plugin module/step/trigger // surface, then syncs into /plugins//manifest.json. -// -// MINIMUM VIABLE port for the parity cycle. Detailed inspect-program logic -// remains to be filled in during Task 2's parity-diff window — this stub -// runs the existing bash script as a fallback when called without --fix -// (dry-run / observation-only mode for the parity gate). -// -// TODO(workflow#762 follow-up): port the inspect.go program embed + JSON -// comparison logic. For Layer (a') parity-cycle: this stub's dry-run output -// matches bash's dry-run output (which is empty when no diffs) — good -// enough to gate the parity check. func runPluginRegistrySyncCore(args []string) error { fs := flag.NewFlagSet("plugin registry-sync core", flag.ContinueOnError) fix := fs.Bool("fix", false, "Apply changes (default: dry-run)") @@ -50,23 +45,352 @@ Replaces workflow-registry/scripts/sync-core-manifests.sh. return fmt.Errorf("--workflow-repo %q must point to a workflow checkout: %w", *workflowRepo, err) } - // Parity-cycle fallback: shell out to the existing bash script if present. - // Lets Layer (a') run BOTH bash + Go to identical effect during the - // observation window, deferring the full port to a follow-up PR. - bashScript := filepath.Join(*registryDir, "scripts", "sync-core-manifests.sh") - if _, err := os.Stat(bashScript); err == nil { - args := []string{} - if *fix { - args = append(args, "--fix") + plugins, err := inspectCoreRegistryPlugins(*workflowRepo) + if err != nil { + return err + } + return syncCorePluginManifests(*registryDir, plugins, *fix, os.Stderr) +} + +type coreRegistryPlugin struct { + Name string `json:"name"` + Version string `json:"version"` + Description string `json:"description"` + ModuleTypes []string `json:"moduleTypes"` + StepTypes []string `json:"stepTypes"` + TriggerTypes []string `json:"triggerTypes"` + WorkflowHandlers []string `json:"workflowHandlers"` + WiringHooks []string `json:"wiringHooks"` +} + +func inspectCoreRegistryPlugins(workflowRepo string) ([]coreRegistryPlugin, error) { + inspectDir, err := os.MkdirTemp(workflowRepo, ".workflow-core-inspect-*") + if err != nil { + return nil, fmt.Errorf("create workflow core inspect dir: %w", err) + } + defer os.RemoveAll(inspectDir) + + if err := os.WriteFile(filepath.Join(inspectDir, "main.go"), []byte(coreRegistryInspectProgram), 0o600); err != nil { + return nil, fmt.Errorf("write workflow core inspector: %w", err) + } + + cmd := exec.Command("go", "run", "./"+filepath.Base(inspectDir)) // #nosec G204 -- fixed go command with generated package path + cmd.Dir = workflowRepo + cmd.Env = append(os.Environ(), "GOWORK=off") + var stderr strings.Builder + cmd.Stderr = &stderr + out, err := cmd.Output() + if err != nil { + if stderr.Len() > 0 { + return nil, fmt.Errorf("inspect workflow core plugins: %w: %s", err, strings.TrimSpace(stderr.String())) } - cmd := exec.Command("bash", append([]string{bashScript}, args...)...) // #nosec G204 -- bashScript is computed from operator-supplied registryDir - cmd.Env = append(os.Environ(), "WORKFLOW_REPO="+*workflowRepo) - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - return cmd.Run() + return nil, fmt.Errorf("inspect workflow core plugins: %w", err) } - fmt.Fprintln(os.Stderr, "wfctl plugin registry-sync core: native Go port pending (workflow#762 follow-up)") - fmt.Fprintln(os.Stderr, " Bash fallback (sync-core-manifests.sh) not found; nothing to do.") + var plugins []coreRegistryPlugin + if err := json.Unmarshal(out, &plugins); err != nil { + return nil, fmt.Errorf("parse workflow core inspector output: %w", err) + } + return plugins, nil +} + +func syncCorePluginManifests(registryDir string, plugins []coreRegistryPlugin, fix bool, stderr io.Writer) error { + pluginsDir := filepath.Join(registryDir, "plugins") + failures := 0 + for i := range plugins { + p := plugins[i] + expectedPath := manifestPathForCorePlugin(pluginsDir, p.Name) + dir := filepath.Base(filepath.Dir(expectedPath)) + expected := expectedCoreManifest(p, "plugins/"+dir) + + if _, err := os.Stat(expectedPath); err != nil { + if !os.IsNotExist(err) { + return fmt.Errorf("stat %s: %w", expectedPath, err) + } + if fix { + if err := writeCoreManifest(expectedPath, expected, nil); err != nil { + return err + } + fmt.Fprintf(stderr, "created %s\n", relRegistryPath(registryDir, expectedPath)) + continue + } + fmt.Fprintf(stderr, "missing core plugin manifest for %s: expected %s\n", p.Name, relRegistryPath(registryDir, expectedPath)) + failures++ + continue + } + + current, currentRaw, err := readNormalizedCoreManifest(expectedPath) + if err != nil { + return err + } + if !reflect.DeepEqual(current, expected) { + if fix { + if err := writeCoreManifest(expectedPath, expected, currentRaw); err != nil { + return err + } + fmt.Fprintf(stderr, "updated %s\n", relRegistryPath(registryDir, expectedPath)) + continue + } + fmt.Fprintf(stderr, "core plugin manifest drift for %s: %s\n", p.Name, relRegistryPath(registryDir, expectedPath)) + failures++ + } + } + if failures > 0 { + fmt.Fprintf(stderr, "core manifest validation failed: %d issue(s)\n", failures) + return fmt.Errorf("core manifest validation failed: %d issue(s)", failures) + } + if fix { + fmt.Fprintln(stderr, "Core plugin manifests synced.") + } else { + fmt.Fprintln(stderr, "Core plugin manifests match workflow plugin declarations.") + } return nil } + +type coreManifest struct { + Name string `json:"name"` + Version string `json:"version"` + Author string `json:"author"` + Description string `json:"description"` + Source string `json:"source"` + Path string `json:"path"` + Type string `json:"type"` + Tier string `json:"tier"` + License string `json:"license"` + Homepage string `json:"homepage"` + Repository string `json:"repository"` + Capabilities coreManifestCapabilities `json:"capabilities"` +} + +type coreManifestCapabilities struct { + ModuleTypes []string `json:"moduleTypes"` + StepTypes []string `json:"stepTypes"` + TriggerTypes []string `json:"triggerTypes"` + WorkflowHandlers []string `json:"workflowHandlers"` + WiringHooks []string `json:"wiringHooks"` +} + +func expectedCoreManifest(p coreRegistryPlugin, path string) coreManifest { + return coreManifest{ + Name: p.Name, + Version: p.Version, + Author: "GoCodeAlone", + Description: p.Description, + Source: "github.com/GoCodeAlone/workflow", + Path: path, + Type: "builtin", + Tier: "core", + License: "MIT", + Homepage: "https://github.com/GoCodeAlone/workflow", + Repository: "https://github.com/GoCodeAlone/workflow", + Capabilities: coreManifestCapabilities{ + ModuleTypes: sortedCopy(p.ModuleTypes), + StepTypes: sortedCopy(p.StepTypes), + TriggerTypes: sortedCopy(p.TriggerTypes), + WorkflowHandlers: sortedCopy(p.WorkflowHandlers), + WiringHooks: sortedCopy(p.WiringHooks), + }, + } +} + +func readNormalizedCoreManifest(path string) (coreManifest, map[string]any, error) { + raw, err := os.ReadFile(path) + if err != nil { + return coreManifest{}, nil, fmt.Errorf("read %s: %w", path, err) + } + var currentRaw map[string]any + if err := json.Unmarshal(raw, ¤tRaw); err != nil { + return coreManifest{}, nil, fmt.Errorf("parse %s: %w", path, err) + } + manifest := coreManifest{ + Name: stringField(currentRaw, "name"), + Version: stringField(currentRaw, "version"), + Author: stringField(currentRaw, "author"), + Description: stringField(currentRaw, "description"), + Source: stringField(currentRaw, "source"), + Path: stringField(currentRaw, "path"), + Type: stringField(currentRaw, "type"), + Tier: stringField(currentRaw, "tier"), + License: stringField(currentRaw, "license"), + Homepage: stringField(currentRaw, "homepage"), + Repository: stringField(currentRaw, "repository"), + } + if caps, _ := currentRaw["capabilities"].(map[string]any); caps != nil { + manifest.Capabilities = coreManifestCapabilities{ + ModuleTypes: sortedStringSlice(caps["moduleTypes"]), + StepTypes: sortedStringSlice(caps["stepTypes"]), + TriggerTypes: sortedStringSlice(caps["triggerTypes"]), + WorkflowHandlers: sortedStringSlice(caps["workflowHandlers"]), + WiringHooks: sortedStringSlice(caps["wiringHooks"]), + } + } + return manifest, currentRaw, nil +} + +func writeCoreManifest(path string, expected coreManifest, current map[string]any) error { + if current == nil { + current = map[string]any{} + } + current["name"] = expected.Name + current["version"] = expected.Version + current["author"] = expected.Author + current["description"] = expected.Description + current["source"] = expected.Source + current["path"] = expected.Path + current["type"] = expected.Type + current["tier"] = expected.Tier + current["license"] = expected.License + current["homepage"] = expected.Homepage + current["repository"] = expected.Repository + delete(current, "downloads") + + caps, _ := current["capabilities"].(map[string]any) + if caps == nil { + caps = map[string]any{} + } + caps["moduleTypes"] = expected.Capabilities.ModuleTypes + caps["stepTypes"] = expected.Capabilities.StepTypes + caps["triggerTypes"] = expected.Capabilities.TriggerTypes + caps["workflowHandlers"] = expected.Capabilities.WorkflowHandlers + caps["wiringHooks"] = expected.Capabilities.WiringHooks + current["capabilities"] = caps + + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { // #nosec G301 -- registry manifests are normal repository files. + return fmt.Errorf("create manifest dir %s: %w", filepath.Dir(path), err) + } + raw, err := json.MarshalIndent(current, "", " ") + if err != nil { + return fmt.Errorf("marshal %s: %w", path, err) + } + raw = append(raw, '\n') + if err := os.WriteFile(path, raw, 0o644); err != nil { // #nosec G306 -- registry manifests are normal repository files. + return fmt.Errorf("write %s: %w", path, err) + } + return nil +} + +func manifestPathForCorePlugin(pluginsDir, name string) string { + if matches, err := filepath.Glob(filepath.Join(pluginsDir, "*", "manifest.json")); err == nil { + for _, path := range matches { + raw, err := os.ReadFile(path) + if err != nil { + continue + } + var manifest struct { + Name string `json:"name"` + } + if json.Unmarshal(raw, &manifest) == nil && manifest.Name == name { + return path + } + } + } + + alias := strings.TrimPrefix(name, "workflow-plugin-") + alias = strings.TrimSuffix(alias, "-plugin") + switch alias { + case "feature-flags": + alias = "featureflags" + case "pipeline-steps": + alias = "pipelinesteps" + case "modular-compat": + alias = "modularcompat" + case "kubernetes-deploy": + alias = "k8s" + } + return filepath.Join(pluginsDir, alias, "manifest.json") +} + +func relRegistryPath(registryDir, path string) string { + rel, err := filepath.Rel(registryDir, path) + if err != nil { + return path + } + return rel +} + +func stringField(m map[string]any, key string) string { + v, _ := m[key].(string) + return v +} + +func sortedStringSlice(v any) []string { + raw, _ := v.([]any) + out := make([]string, 0, len(raw)) + for _, item := range raw { + if s, ok := item.(string); ok { + out = append(out, s) + } + } + sort.Strings(out) + return out +} + +func sortedCopy(in []string) []string { + out := append([]string{}, in...) + sort.Strings(out) + return out +} + +const coreRegistryInspectProgram = `package main + +import ( + "encoding/json" + "os" + "sort" + + "github.com/GoCodeAlone/workflow/plugin" + "github.com/GoCodeAlone/workflow/plugins/all" +) + +type corePlugin struct { + Name string ` + "`json:\"name\"`" + ` + Version string ` + "`json:\"version\"`" + ` + Description string ` + "`json:\"description\"`" + ` + ModuleTypes []string ` + "`json:\"moduleTypes\"`" + ` + StepTypes []string ` + "`json:\"stepTypes\"`" + ` + TriggerTypes []string ` + "`json:\"triggerTypes\"`" + ` + WorkflowHandlers []string ` + "`json:\"workflowHandlers\"`" + ` + WiringHooks []string ` + "`json:\"wiringHooks\"`" + ` +} + +func mapKeys[T any](m map[string]T) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} + +func hookNames(hooks []plugin.WiringHook) []string { + names := make([]string, 0, len(hooks)) + for _, hook := range hooks { + if hook.Name != "" { + names = append(names, hook.Name) + } + } + sort.Strings(names) + return names +} + +func main() { + out := make([]corePlugin, 0) + for _, p := range all.DefaultPlugins() { + m := p.EngineManifest() + out = append(out, corePlugin{ + Name: m.Name, + Version: m.Version, + Description: m.Description, + ModuleTypes: mapKeys(p.ModuleFactories()), + StepTypes: mapKeys(p.StepFactories()), + TriggerTypes: mapKeys(p.TriggerFactories()), + WorkflowHandlers: mapKeys(p.WorkflowHandlers()), + WiringHooks: hookNames(p.WiringHooks()), + }) + } + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + if err := json.NewEncoder(os.Stdout).Encode(out); err != nil { + panic(err) + } +} +` diff --git a/cmd/wfctl/plugin_registry_sync_readme.go b/cmd/wfctl/plugin_registry_sync_readme.go index 11e98bb8..0b17da7c 100644 --- a/cmd/wfctl/plugin_registry_sync_readme.go +++ b/cmd/wfctl/plugin_registry_sync_readme.go @@ -1,22 +1,18 @@ package main import ( + "encoding/json" "flag" "fmt" "os" - "os/exec" "path/filepath" + "sort" + "strings" ) // runPluginRegistrySyncReadme ports workflow-registry/scripts/generate-readme.sh -// (workflow#762 Layer a). Regenerates the plugin/template indexes in -// /README.md between marker comments. -// -// MINIMUM VIABLE port for the parity cycle — shells out to the existing -// bash script during Layer (a') so dry-run parity holds. Native Go port -// (with the 7-template enumeration + pipe-escape + case-fold sort + marker -// region replacement per plan I-P5) lands in a follow-up PR within the -// parity-cycle window. +// (workflow#762). Regenerates the plugin/template indexes in +// /README.md from registry source data. func runPluginRegistrySyncReadme(args []string) error { fs := flag.NewFlagSet("plugin registry-sync readme", flag.ContinueOnError) check := fs.Bool("check", false, "Dry-run; exit non-zero on diff") @@ -35,23 +31,230 @@ Replaces workflow-registry/scripts/generate-readme.sh. return err } - // Parity-cycle fallback: shell out to the existing bash script. - // Layer (a') runs Go (--check) alongside bash (--check); parity-diff - // asserts identical output. Native Go port deferred per Task 1 §5. - bashScript := filepath.Join(*registryDir, "scripts", "generate-readme.sh") - if _, err := os.Stat(bashScript); err == nil { - cmdArgs := []string{bashScript} - if *check { - cmdArgs = append(cmdArgs, "--check") + readmePath := filepath.Join(*registryDir, "README.md") + current, err := os.ReadFile(readmePath) + if err != nil { + return fmt.Errorf("read README.md: %w", err) + } + next, err := renderRegistryReadme(*registryDir, string(current)) + if err != nil { + return err + } + if *check { + if string(current) != next { + fmt.Fprintln(os.Stderr, "README.md is out of date; run wfctl plugin registry-sync readme") + return fmt.Errorf("README.md is out of date") } - cmd := exec.Command("bash", cmdArgs...) // #nosec G204 -- bashScript path is computed from operator-supplied registryDir - cmd.Dir = *registryDir - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - return cmd.Run() + return nil + } + if err := os.WriteFile(readmePath, []byte(next), 0o644); err != nil { // #nosec G306 -- README.md is a normal repository file. + return fmt.Errorf("write README.md: %w", err) } - - fmt.Fprintln(os.Stderr, "wfctl plugin registry-sync readme: native Go port pending (workflow#762 follow-up)") - fmt.Fprintln(os.Stderr, " Bash fallback (generate-readme.sh) not found; nothing to do.") return nil } + +type registryReadmePlugin struct { + Dir string + Name string `json:"name"` + Description string `json:"description"` + Type string `json:"type"` + Tier string `json:"tier"` +} + +func renderRegistryReadme(registryDir, current string) (string, error) { + plugins, err := loadRegistryReadmePlugins(filepath.Join(registryDir, "plugins")) + if err != nil { + return "", err + } + templates, err := loadRegistryReadmeTemplates(filepath.Join(registryDir, "templates")) + if err != nil { + return "", err + } + + var b strings.Builder + b.WriteString(readmePrefix(current)) + if b.Len() > 0 && !strings.HasSuffix(b.String(), "\n") { + b.WriteByte('\n') + } + b.WriteString(`## Built-in Plugins + +These plugins ship in the ` + "`GoCodeAlone/workflow`" + ` engine and are available without installing a separate plugin repository. + +| Plugin | Description | +|--------|-------------| +`) + writeRegistryReadmePluginRows(&b, plugins, func(p registryReadmePlugin) bool { + return p.Type == "builtin" + }, false) + + b.WriteString(` +## First-party External Plugins + +These plugins are maintained by GoCodeAlone as core platform capabilities, but are distributed outside the engine repository. + +| Plugin | Description | +|--------|-------------| +`) + writeRegistryReadmePluginRows(&b, plugins, func(p registryReadmePlugin) bool { + return p.Type == "external" && p.Tier == "core" + }, false) + + b.WriteString(` +## Community and Premium External Plugins + +These plugins are distributed outside the engine repository and are maintained as community or commercial extensions. + +| Plugin | Description | Tier | +|--------|-------------|------| +`) + writeRegistryReadmePluginRows(&b, plugins, func(p registryReadmePlugin) bool { + return p.Type == "external" && p.Tier != "core" + }, true) + + b.WriteString(` +## Templates + +Starter configurations for common workflow patterns: + +| Template | Description | +|----------|-------------| +`) + for _, tmpl := range templates { + fmt.Fprintf(&b, "| [%s](./templates/%s) | %s |\n", tmpl.Name, tmpl.File, escapeRegistryReadmeCell(tmpl.Description)) + } + b.WriteString(` +Initialize a project from a template: + +` + "```bash" + ` +wfctl init my-project --template api-service +` + "```" + ` + +--- + +`) + b.WriteString(readmeSchemaSuffix(current)) + return b.String(), nil +} + +func loadRegistryReadmePlugins(pluginsDir string) ([]registryReadmePlugin, error) { + matches, err := filepath.Glob(filepath.Join(pluginsDir, "*", "manifest.json")) + if err != nil { + return nil, err + } + plugins := make([]registryReadmePlugin, 0, len(matches)) + for _, path := range matches { + raw, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read %s: %w", path, err) + } + var p registryReadmePlugin + if err := json.Unmarshal(raw, &p); err != nil { + return nil, fmt.Errorf("parse %s: %w", path, err) + } + p.Dir = filepath.Base(filepath.Dir(path)) + plugins = append(plugins, p) + } + sort.SliceStable(plugins, func(i, j int) bool { + left := strings.ToLower(registryReadmePluginLink(plugins[i].Dir)) + right := strings.ToLower(registryReadmePluginLink(plugins[j].Dir)) + if left == right { + return plugins[i].Dir < plugins[j].Dir + } + return left < right + }) + return plugins, nil +} + +type registryReadmeTemplate struct { + File string + Name string + Description string +} + +func loadRegistryReadmeTemplates(templatesDir string) ([]registryReadmeTemplate, error) { + matches, err := filepath.Glob(filepath.Join(templatesDir, "*.yaml")) + if err != nil { + return nil, err + } + sort.Strings(matches) + out := make([]registryReadmeTemplate, 0, len(matches)) + for _, path := range matches { + desc, err := registryTemplateDescription(path) + if err != nil { + return nil, err + } + file := filepath.Base(path) + out = append(out, registryReadmeTemplate{ + File: file, + Name: strings.TrimSuffix(file, filepath.Ext(file)), + Description: desc, + }) + } + return out, nil +} + +func registryTemplateDescription(path string) (string, error) { + raw, err := os.ReadFile(path) + if err != nil { + return "", fmt.Errorf("read %s: %w", path, err) + } + for _, line := range strings.Split(string(raw), "\n") { + if strings.HasPrefix(line, "description:") { + return strings.TrimSpace(strings.TrimPrefix(line, "description:")), nil + } + } + return "", nil +} + +func writeRegistryReadmePluginRows( + b *strings.Builder, + plugins []registryReadmePlugin, + include func(registryReadmePlugin) bool, + includeTier bool, +) { + for _, p := range plugins { + if !include(p) { + continue + } + if includeTier { + fmt.Fprintf(b, "| %s | %s | %s |\n", + registryReadmePluginLink(p.Dir), escapeRegistryReadmeCell(p.Description), p.Tier) + continue + } + fmt.Fprintf(b, "| %s | %s |\n", + registryReadmePluginLink(p.Dir), escapeRegistryReadmeCell(p.Description)) + } +} + +func registryReadmePluginLink(dir string) string { + return fmt.Sprintf("[%s](./plugins/%s/manifest.json)", dir, dir) +} + +func escapeRegistryReadmeCell(s string) string { + s = strings.ReplaceAll(s, "\n", " ") + return strings.ReplaceAll(s, "|", `\|`) +} + +func readmePrefix(current string) string { + headings := []string{ + "## Built-in Plugins", + "## Core Plugins", + "## External Plugins", + "## First-party External Plugins", + "## Community and Premium External Plugins", + } + idx := len(current) + for _, h := range headings { + if pos := strings.Index(current, h); pos >= 0 && pos < idx { + idx = pos + } + } + return current[:idx] +} + +func readmeSchemaSuffix(current string) string { + if idx := strings.Index(current, "## Schema"); idx >= 0 { + return current[idx:] + } + return "" +} diff --git a/cmd/wfctl/plugin_registry_sync_test.go b/cmd/wfctl/plugin_registry_sync_test.go index 0472580e..29ab86dc 100644 --- a/cmd/wfctl/plugin_registry_sync_test.go +++ b/cmd/wfctl/plugin_registry_sync_test.go @@ -4,6 +4,7 @@ import ( "archive/tar" "bytes" "compress/gzip" + "encoding/json" "errors" "os" "path/filepath" @@ -148,6 +149,103 @@ func TestPluginRegistrySync_UsageHelp(t *testing.T) { } } +func TestPluginRegistrySyncReadme_CheckDetectsDriftWithoutBash(t *testing.T) { + registry := t.TempDir() + mustWrite(t, filepath.Join(registry, "README.md"), "# Registry\n\n## Schema\n\nschema docs\n") + mustWrite(t, filepath.Join(registry, "plugins", "alpha", "manifest.json"), `{ + "name": "alpha", + "description": "Alpha | plugin", + "type": "builtin", + "tier": "core" +}`) + mustWrite(t, filepath.Join(registry, "templates", "api-service.yaml"), "description: API | service\n") + + err := runPluginRegistrySyncReadme([]string{"--check", "--registry-dir", registry}) + if err == nil || !strings.Contains(err.Error(), "README.md is out of date") { + t.Fatalf("check error = %v, want README drift error", err) + } + + if err := runPluginRegistrySyncReadme([]string{"--registry-dir", registry}); err != nil { + t.Fatalf("runPluginRegistrySyncReadme returned error: %v", err) + } + got, err := os.ReadFile(filepath.Join(registry, "README.md")) + if err != nil { + t.Fatal(err) + } + text := string(got) + for _, want := range []string{ + "## Built-in Plugins", + "| [alpha](./plugins/alpha/manifest.json) | Alpha \\| plugin |", + "## Templates", + "| [api-service](./templates/api-service.yaml) | API \\| service |", + "## Schema", + } { + if !strings.Contains(text, want) { + t.Fatalf("README missing %q:\n%s", want, text) + } + } +} + +func TestPluginRegistrySyncCore_DetectsAndFixesManifestDrift(t *testing.T) { + registry := t.TempDir() + manifest := filepath.Join(registry, "plugins", "corealpha", "manifest.json") + mustWrite(t, manifest, `{ + "name": "workflow-plugin-core-alpha", + "version": "0.1.0", + "author": "GoCodeAlone", + "description": "stale", + "source": "github.com/GoCodeAlone/workflow", + "path": "plugins/corealpha", + "type": "builtin", + "tier": "core", + "license": "MIT", + "homepage": "https://github.com/GoCodeAlone/workflow", + "repository": "https://github.com/GoCodeAlone/workflow", + "downloads": [{"os": "linux"}], + "capabilities": {"moduleTypes": ["old"]} +}`) + + plugins := []coreRegistryPlugin{{ + Name: "workflow-plugin-core-alpha", + Version: "1.2.3", + Description: "current", + ModuleTypes: []string{"alpha"}, + StepTypes: []string{"step"}, + TriggerTypes: []string{"trigger"}, + WorkflowHandlers: []string{"handler"}, + WiringHooks: []string{"hook"}, + }} + + var stderr bytes.Buffer + err := syncCorePluginManifests(registry, plugins, false, &stderr) + if err == nil || !strings.Contains(err.Error(), "core manifest validation failed") { + t.Fatalf("dry-run error = %v, stderr=%s", err, stderr.String()) + } + + stderr.Reset() + if err := syncCorePluginManifests(registry, plugins, true, &stderr); err != nil { + t.Fatalf("fix returned error: %v", err) + } + raw, err := os.ReadFile(manifest) + if err != nil { + t.Fatal(err) + } + var got map[string]any + if err := json.Unmarshal(raw, &got); err != nil { + t.Fatal(err) + } + if got["version"] != "1.2.3" || got["description"] != "current" { + t.Fatalf("manifest not updated: %#v", got) + } + if _, ok := got["downloads"]; ok { + t.Fatalf("downloads should be removed from builtin core manifest: %#v", got) + } + caps := got["capabilities"].(map[string]any) + if len(caps["moduleTypes"].([]any)) != 1 || caps["moduleTypes"].([]any)[0] != "alpha" { + t.Fatalf("capabilities not updated: %#v", caps) + } +} + func TestPluginRegistrySync_SelectPlatformReleaseAsset(t *testing.T) { assets := []releaseAsset{ {Name: "workflow-plugin-foo-linux-amd64.tar.gz", OS: "linux", Arch: "amd64", URL: "linux-amd64"}, @@ -303,3 +401,13 @@ func writeTestTarGz(path, name string, data []byte, mode int64) error { } return os.WriteFile(path, buf.Bytes(), 0o644) } + +func mustWrite(t *testing.T, path, content string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } +} diff --git a/docs/PLUGIN_RELEASE_GATES.md b/docs/PLUGIN_RELEASE_GATES.md index 224c1d6b..69f823be 100644 --- a/docs/PLUGIN_RELEASE_GATES.md +++ b/docs/PLUGIN_RELEASE_GATES.md @@ -143,12 +143,12 @@ wfctl plugin registry-sync core --workflow-repo [--fix] [--registry-dir < wfctl plugin registry-sync readme [--check] [--registry-dir ] ``` -Replaces three bash scripts (`scripts/sync-versions.sh`, +Replaces three registry maintenance scripts (`scripts/sync-versions.sh`, `scripts/sync-core-manifests.sh`, `scripts/generate-readme.sh`) with one -Go entrypoint. During the workflow#762 parity cycle, the Go subcommand -runs in dry-run alongside the authoritative bash; once parity is -confirmed for one full cron cycle, a follow-up PR deletes the bash -scripts and swaps `--fix` ownership. +Go entrypoint. `registry-sync`, `registry-sync core`, and +`registry-sync readme` own the native behavior; `workflow-registry` can keep +thin compatibility wrappers during migration, but the source of truth should +be the `wfctl` implementation. **Defense in depth — type allowlist:** registry-sync rejects any `plugin.json.type` value outside `{external, builtin, core, iac}`. In From 4cabdd13f4b4c3d9204fc5a61470cc620e10f9e7 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Mon, 1 Jun 2026 20:48:04 -0400 Subject: [PATCH 2/2] fix(wfctl): avoid duplicate registry README schema --- cmd/wfctl/plugin_registry_sync_readme.go | 2 ++ cmd/wfctl/plugin_registry_sync_test.go | 3 +++ 2 files changed, 5 insertions(+) diff --git a/cmd/wfctl/plugin_registry_sync_readme.go b/cmd/wfctl/plugin_registry_sync_readme.go index 0b17da7c..ce7b4347 100644 --- a/cmd/wfctl/plugin_registry_sync_readme.go +++ b/cmd/wfctl/plugin_registry_sync_readme.go @@ -242,6 +242,8 @@ func readmePrefix(current string) string { "## External Plugins", "## First-party External Plugins", "## Community and Premium External Plugins", + "## Templates", + "## Schema", } idx := len(current) for _, h := range headings { diff --git a/cmd/wfctl/plugin_registry_sync_test.go b/cmd/wfctl/plugin_registry_sync_test.go index 29ab86dc..ac858616 100644 --- a/cmd/wfctl/plugin_registry_sync_test.go +++ b/cmd/wfctl/plugin_registry_sync_test.go @@ -184,6 +184,9 @@ func TestPluginRegistrySyncReadme_CheckDetectsDriftWithoutBash(t *testing.T) { t.Fatalf("README missing %q:\n%s", want, text) } } + if got := strings.Count(text, "## Schema"); got != 1 { + t.Fatalf("README schema section count = %d, want 1:\n%s", got, text) + } } func TestPluginRegistrySyncCore_DetectsAndFixesManifestDrift(t *testing.T) {