feat: multi-registry plugin distribution system#184
Conversation
Add configurable multi-registry support for plugin discovery, validation, and installation. The default registry remains GoCodeAlone/workflow-registry but additional GitHub-backed registries can be configured via .wfctl.yaml or ~/.config/wfctl/config.yaml. New files: - registry_config.go: YAML config for registry sources with priority - registry_source.go: RegistrySource interface + GitHubRegistrySource - multi_registry.go: priority-based aggregator with dedup search/list - registry_cmd.go: `wfctl registry list|add|remove` commands - registry_validate.go: manifest validation (schema, semver, enums, SHA256, URLs) - plugin_validate.go: `wfctl plugin validate` command (single, --all, --file) - multi_registry_test.go: 24 unit tests for config, multi-registry, validation - plugin_install_e2e_test.go: end-to-end install pipeline test with httptest Modified: - plugin_install.go: wired through MultiRegistry, added --config/--registry flags, binary rename for ExternalPluginManager compatibility - plugin.go: added validate subcommand - main.go: added registry command Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds multi-registry plugin distribution support to wfctl, including registry configuration/management, manifest validation, and updated search/install flows to work across multiple GitHub-backed registries.
Changes:
- Introduces
RegistrySource+MultiRegistryabstractions with priority ordering, fallback, and deduplicated search/list behavior. - Adds registry config loading/saving (
.wfctl.yaml/~/.config/wfctl/config.yaml) plus newwfctl registry list|add|removecommands. - Adds
wfctl plugin validateand extends install to normalize extracted binary names forExternalPluginManagerdiscovery, with extensive new tests (unit + e2e).
Reviewed changes
Copilot reviewed 11 out of 11 changed files in this pull request and generated 12 comments.
Show a summary per file
| File | Description |
|---|---|
| cmd/wfctl/registry_validate.go | Adds manifest validation (required fields/enums/semver/sha256/URL checks) and helpers. |
| cmd/wfctl/registry_source.go | Adds GitHub-backed registry implementation and shared search matching helper. |
| cmd/wfctl/registry_config.go | Adds registry config model + load/save behavior. |
| cmd/wfctl/registry_cmd.go | Implements wfctl registry CLI subcommands. |
| cmd/wfctl/plugin_validate.go | Implements wfctl plugin validate command flows (single/all/file). |
| cmd/wfctl/multi_registry.go | Implements multi-registry aggregation (priority, fallback, dedup). |
| cmd/wfctl/multi_registry_test.go | Adds unit tests for config, multi-registry behavior, and manifest validation helpers. |
| cmd/wfctl/plugin_install.go | Updates search/install to use multi-registry + adds binary normalization helper. |
| cmd/wfctl/plugin_install_e2e_test.go | Adds httptest-based end-to-end install/extract/discovery tests + helper tests. |
| cmd/wfctl/plugin.go | Wires plugin validate subcommand into plugin CLI. |
| cmd/wfctl/main.go | Wires top-level registry command into CLI. |
| func ensurePluginBinary(destDir, pluginName string) error { | ||
| expectedPath := filepath.Join(destDir, pluginName) | ||
| if info, err := os.Stat(expectedPath); err == nil && !info.IsDir() { | ||
| return nil // already correctly named | ||
| } |
There was a problem hiding this comment.
ensurePluginBinary() introduces important install behavior (normalizing the extracted binary name), but there’s no direct unit test covering its rename/selection logic (including Windows behavior). Consider adding focused tests for ensurePluginBinary so regressions don’t break plugin discovery.
| func (g *GitHubRegistrySource) Name() string { return g.name } | ||
|
|
||
| func (g *GitHubRegistrySource) ListPlugins() ([]string, error) { | ||
| url := fmt.Sprintf("https://api.github.com/repos/%s/%s/contents/plugins", g.owner, g.repo) |
There was a problem hiding this comment.
GitHub contents API listing ignores the configured branch: ListPlugins() always lists from the repo’s default branch, but FetchManifest() uses g.branch. This can cause search/list to show plugins that don’t exist on the configured branch (or miss ones that do). Consider adding the ref query parameter (or otherwise incorporating g.branch) so listing and fetching are consistent.
| url := fmt.Sprintf("https://api.github.com/repos/%s/%s/contents/plugins", g.owner, g.repo) | |
| url := fmt.Sprintf("https://api.github.com/repos/%s/%s/contents/plugins?ref=%s", g.owner, g.repo, g.branch) |
| opts := ValidationOptions{ | ||
| VerifyURLs: *verifyURLs, | ||
| TargetOS: runtime.GOOS, | ||
| TargetArch: runtime.GOARCH, | ||
| } |
There was a problem hiding this comment.
ValidationOptions.EngineVersion is never set for wfctl plugin validate, so the minEngineVersion compatibility check in ValidateManifest() will never run from this command. If the intent is to validate against the current engine version, consider setting opts.EngineVersion (e.g., to the CLI’s version value, or an engine version constant) and/or exposing a flag to override it.
| paths := []string{} | ||
| if explicitPath != "" { | ||
| paths = append(paths, explicitPath) | ||
| } | ||
| paths = append(paths, ".wfctl.yaml") |
There was a problem hiding this comment.
When explicitPath is provided, LoadRegistryConfig() still silently falls back to other locations/defaults if that file can’t be read. This can mask typos and make debugging config issues difficult. Consider returning an error when explicitPath is set and ReadFile fails (or at least when it fails for reasons other than os.IsNotExist).
| paths := []string{} | |
| if explicitPath != "" { | |
| paths = append(paths, explicitPath) | |
| } | |
| paths = append(paths, ".wfctl.yaml") | |
| if explicitPath != "" { | |
| data, err := os.ReadFile(explicitPath) | |
| if err != nil { | |
| if !os.IsNotExist(err) { | |
| return nil, fmt.Errorf("read registry config %s: %w", explicitPath, err) | |
| } | |
| } else { | |
| var cfg RegistryConfig | |
| if err := yaml.Unmarshal(data, &cfg); err != nil { | |
| return nil, fmt.Errorf("parse registry config %s: %w", explicitPath, err) | |
| } | |
| // Ensure defaults | |
| for i := range cfg.Registries { | |
| if cfg.Registries[i].Branch == "" { | |
| cfg.Registries[i].Branch = "main" | |
| } | |
| if cfg.Registries[i].Type == "" { | |
| cfg.Registries[i].Type = "github" | |
| } | |
| } | |
| return &cfg, nil | |
| } | |
| } | |
| paths := []string{".wfctl.yaml"} |
| // compareSemver compares two semver strings. Returns -1, 0, or 1. | ||
| func compareSemver(a, b string) int { | ||
| parseVer := func(s string) (int, int, int) { | ||
| var major, minor, patch int | ||
| fmt.Sscanf(s, "%d.%d.%d", &major, &minor, &patch) //nolint:errcheck // format is validated by semverRegex | ||
| return major, minor, patch | ||
| } |
There was a problem hiding this comment.
compareSemver uses fmt.Sscanf("%d.%d.%d") and ignores pre-release/build metadata, so comparisons involving versions like "1.0.0-beta" won’t follow semver ordering rules. Since this gates MinEngineVersion compatibility, consider using a semver library comparator instead of manual parsing.
| regType := fs.String("type", "github", "Registry type (github)") | ||
| owner := fs.String("owner", "", "GitHub owner/org (required)") | ||
| repo := fs.String("repo", "", "GitHub repo name (required)") |
There was a problem hiding this comment.
registry add accepts any value for --type and writes it to config, but NewMultiRegistry() will later skip unknown types. Consider validating --type (currently only "github") and returning an error for unsupported values so users don’t create broken registry entries.
| TargetArch string // Filter downloads by arch | ||
| } | ||
|
|
||
| var semverRegex = regexp.MustCompile(`^\d+\.\d+\.\d+`) |
There was a problem hiding this comment.
semverRegex is only a prefix match (not end-anchored), so invalid versions like "1.2.3beta" or "1.2.3.4" will pass validation. If the registry requires real semver, consider switching to a proper semver validator or tightening the regex to match the full string.
| var semverRegex = regexp.MustCompile(`^\d+\.\d+\.\d+`) | |
| var semverRegex = regexp.MustCompile(`^\d+\.\d+\.\d+$`) |
| // Ensure the plugin binary is named to match the plugin name so that | ||
| // ExternalPluginManager.DiscoverPlugins() can find it (expects <dir>/<name>/<name>). | ||
| if err := ensurePluginBinary(destDir, pluginName); err != nil { | ||
| fmt.Fprintf(os.Stderr, "warning: could not normalize binary name: %v\n", err) |
There was a problem hiding this comment.
If ensurePluginBinary() fails, plugin install continues with only a warning. When the extracted binary isn’t already named /, this results in a successful-looking install that ExternalPluginManager won’t discover. Consider making this a hard error when the expected binary name is missing after extraction/normalization (at least for external plugins), so users don’t end up with silently broken installs.
| fmt.Fprintf(os.Stderr, "warning: could not normalize binary name: %v\n", err) | |
| return fmt.Errorf("normalize binary name: %w", err) |
| VerifyURLs bool // HEAD-check download URLs | ||
| VerifyChecksums bool // Verify SHA256 format (not content) | ||
| EngineVersion string // Current engine version for minEngineVersion check | ||
| TargetOS string // Filter downloads by OS | ||
| TargetArch string // Filter downloads by arch |
There was a problem hiding this comment.
ValidationOptions.VerifyChecksums is defined but isn’t used anywhere in ValidateManifest(); checksum-format validation runs regardless. Either wire this option into the SHA256 checks or remove it to avoid a misleading API.
| VerifyURLs bool // HEAD-check download URLs | |
| VerifyChecksums bool // Verify SHA256 format (not content) | |
| EngineVersion string // Current engine version for minEngineVersion check | |
| TargetOS string // Filter downloads by OS | |
| TargetArch string // Filter downloads by arch | |
| VerifyURLs bool // HEAD-check download URLs | |
| EngineVersion string // Current engine version for minEngineVersion check | |
| TargetOS string // Filter downloads by OS | |
| TargetArch string // Filter downloads by arch |
| // Skip non-executable files | ||
| if info.Mode()&0111 == 0 { | ||
| continue | ||
| } |
There was a problem hiding this comment.
ensurePluginBinary filters candidates using POSIX executable bits (mode & 0111). On Windows this is typically unset, so the binary may never be renamed and ExternalPluginManager discovery will fail. Consider handling GOOS=="windows" specially (e.g., accept .exe files or skip the exec-bit filter).
The artifact path is sanitized via TrimPrefix + filepath.Join in artifactPath(), making these path traversal warnings false positives. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Summary
GoCodeAlone/workflow-registry; additional GitHub-backed registries configurable via.wfctl.yamlor~/.config/wfctl/config.yamlwfctl registry list|add|removecommands for managing registry sourceswfctl plugin validatecommand with schema validation, semver checks, enum validation, SHA256 format checks, and optional URL reachability verificationExternalPluginManagercompatibilityNew Commands
Test plan
🤖 Generated with Claude Code