Skip to content

feat: multi-registry plugin distribution system#184

Merged
intel352 merged 2 commits intomainfrom
feature/multi-registry-plugin-distribution
Feb 27, 2026
Merged

feat: multi-registry plugin distribution system#184
intel352 merged 2 commits intomainfrom
feature/multi-registry-plugin-distribution

Conversation

@intel352
Copy link
Contributor

Summary

  • Adds configurable multi-registry support for plugin discovery, validation, and installation
  • Default registry remains GoCodeAlone/workflow-registry; additional GitHub-backed registries configurable via .wfctl.yaml or ~/.config/wfctl/config.yaml
  • New wfctl registry list|add|remove commands for managing registry sources
  • New wfctl plugin validate command with schema validation, semver checks, enum validation, SHA256 format checks, and optional URL reachability verification
  • Plugin install now auto-renames extracted binaries to match plugin name for ExternalPluginManager compatibility
  • 39 new tests covering multi-registry priority/fallback, deduplication, manifest validation, and end-to-end install pipeline

New Commands

wfctl registry list              # show configured registries
wfctl registry add <name> --owner X --repo Y
wfctl registry remove <name>
wfctl plugin validate <name>     # validate from registry
wfctl plugin validate --all      # validate all manifests
wfctl plugin validate --file manifest.json
wfctl plugin install --registry my-org admin  # install from specific registry

Test plan

  • Unit tests: config load/save, multi-registry priority resolution, fallback, dedup search/list (24 tests)
  • Validation tests: required fields, enums, semver, SHA256 format, engine version compat (14 tests)
  • E2E tests: httptest-based full install pipeline with ExternalPluginManager discovery
  • Live validation: all 23 workflow-registry manifests validated (22 pass, 1 expected fail for ratchet)
  • Live install: admin and bento plugins installed, extracted, binary renamed, discoverable
  • golangci-lint: 0 issues
  • Full test suite: all passing

🤖 Generated with Claude Code

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>
Copilot AI review requested due to automatic review settings February 27, 2026 02:37
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 + MultiRegistry abstractions with priority ordering, fallback, and deduplicated search/list behavior.
  • Adds registry config loading/saving (.wfctl.yaml / ~/.config/wfctl/config.yaml) plus new wfctl registry list|add|remove commands.
  • Adds wfctl plugin validate and extends install to normalize extracted binary names for ExternalPluginManager discovery, 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.

Comment on lines +384 to +388
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
}
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot generated this review using guidance from organization custom instructions.
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)
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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)

Copilot uses AI. Check for mistakes.
Comment on lines +25 to +29
opts := ValidationOptions{
VerifyURLs: *verifyURLs,
TargetOS: runtime.GOOS,
TargetArch: runtime.GOARCH,
}
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +46 to +50
paths := []string{}
if explicitPath != "" {
paths = append(paths, explicitPath)
}
paths = append(paths, ".wfctl.yaml")
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
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"}

Copilot uses AI. Check for mistakes.
Comment on lines +139 to +145
// 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
}
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +64 to +66
regType := fs.String("type", "github", "Registry type (github)")
owner := fs.String("owner", "", "GitHub owner/org (required)")
repo := fs.String("repo", "", "GitHub repo name (required)")
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
TargetArch string // Filter downloads by arch
}

var semverRegex = regexp.MustCompile(`^\d+\.\d+\.\d+`)
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
var semverRegex = regexp.MustCompile(`^\d+\.\d+\.\d+`)
var semverRegex = regexp.MustCompile(`^\d+\.\d+\.\d+$`)

Copilot uses AI. Check for mistakes.
// 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)
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
fmt.Fprintf(os.Stderr, "warning: could not normalize binary name: %v\n", err)
return fmt.Errorf("normalize binary name: %w", err)

Copilot uses AI. Check for mistakes.
Comment on lines +23 to +27
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
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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

Copilot uses AI. Check for mistakes.
Comment on lines +405 to +408
// Skip non-executable files
if info.Mode()&0111 == 0 {
continue
}
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
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>
@intel352 intel352 merged commit 8276b5a into main Feb 27, 2026
14 checks passed
@intel352 intel352 deleted the feature/multi-registry-plugin-distribution branch February 27, 2026 02:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants