Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
768d7e8
docs: design plugin conformance compatibility
intel352 May 11, 2026
23325c3
docs: tighten plugin compatibility design
intel352 May 11, 2026
929bb5d
docs: specify plugin compat rollout semantics
intel352 May 11, 2026
fba3496
docs: record final compat design review
intel352 May 11, 2026
9708184
docs: add compatibility evidence production path
intel352 May 11, 2026
8f0f2f3
docs: make plugin conformance artifact-first
intel352 May 11, 2026
9c7a770
docs: record artifact-first design review
intel352 May 11, 2026
fd1246a
docs: clarify conformance evidence scope
intel352 May 11, 2026
361b803
docs: pass conformance design review
intel352 May 11, 2026
3af5fad
docs: plan plugin compat implementation
intel352 May 11, 2026
43a3c12
docs: align compat plan with plugin registry
intel352 May 11, 2026
d46a134
docs: specify conformance fixture setup
intel352 May 11, 2026
eadf5fc
docs: pass compat plan review
intel352 May 11, 2026
df6c5aa
docs: cover compat plan alignment gaps
intel352 May 11, 2026
0de82b6
docs: pass compat alignment check
intel352 May 11, 2026
4a3bf96
chore: lock scope for plugin compat
intel352 May 11, 2026
a9b4a67
feat(wfctl): add plugin compat evidence model
intel352 May 11, 2026
7933f53
feat(wfctl): fetch plugin version indexes
intel352 May 11, 2026
d30eec6
feat(wfctl): add plugin conformance command
intel352 May 11, 2026
8feb2fc
feat(wfctl): update registry compat indexes
intel352 May 11, 2026
e171ce7
feat(wfctl): resolve plugins by compat evidence
intel352 May 11, 2026
adbda39
feat(wfctl): lock plugin compat metadata
intel352 May 11, 2026
dc4e73f
docs(wfctl): document plugin compat conformance
intel352 May 11, 2026
e939623
fix(wfctl): tidy conformance dependency
intel352 May 11, 2026
4847c18
fix(wfctl): avoid compat range value copies
intel352 May 11, 2026
b992479
fix(wfctl): harden plugin compat review gaps
intel352 May 11, 2026
c7cdef0
fix(wfctl): harden conformance ci
intel352 May 11, 2026
d693d74
fix(wfctl): close compat review gaps
intel352 May 11, 2026
e8056a7
fix(wfctl): address compat review followups
intel352 May 11, 2026
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
15 changes: 14 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -65,10 +65,23 @@ jobs:
run: |
go test -v -race -coverprofile=coverage.out ./...

- name: Check Codecov token
id: codecov-token
if: always()
run: |
if [ -n "$CODECOV_TOKEN" ]; then
echo "available=true" >> "$GITHUB_OUTPUT"
else
echo "available=false" >> "$GITHUB_OUTPUT"
fi
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}

- name: Upload coverage reports
uses: codecov/codecov-action@v5
if: always()
if: always() && steps.codecov-token.outputs.available == 'true'
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ./coverage.out
fail_ci_if_error: false

Expand Down
98 changes: 93 additions & 5 deletions cmd/wfctl/multi_registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,20 +24,20 @@ func NewMultiRegistry(cfg *RegistryConfig) *MultiRegistry {
})

sources := make([]RegistrySource, 0, len(sorted))
for _, sc := range sorted {
switch sc.Type {
for i := range sorted {
switch sorted[i].Type {
case "github":
sources = append(sources, NewGitHubRegistrySource(sc))
sources = append(sources, NewGitHubRegistrySource(sorted[i]))
case "static":
src, err := NewStaticRegistrySource(sc)
src, err := NewStaticRegistrySource(sorted[i])
if err != nil {
fmt.Fprintf(os.Stderr, "warning: %v, skipping\n", err)
continue
}
sources = append(sources, src)
default:
// Skip unknown types
fmt.Fprintf(os.Stderr, "warning: unknown registry type %q for %q, skipping\n", sc.Type, sc.Name)
fmt.Fprintf(os.Stderr, "warning: unknown registry type %q for %q, skipping\n", sorted[i].Type, sorted[i].Name)
}
}

Expand Down Expand Up @@ -135,6 +135,94 @@ func (m *MultiRegistry) FetchManifest(name string) (*RegistryManifest, string, e
return nil, "", fmt.Errorf("plugin %q not found in any configured registry", name)
}

// FetchVersionIndex tries each source in priority order, using the same
// original-name then normalized-name lookup order as FetchManifest.
func (m *MultiRegistry) FetchVersionIndex(name string) (*PluginVersionIndex, string, error) {
if len(m.sources) == 0 {
return nil, "", fmt.Errorf("plugin %q not found: no registry sources configured"+
" (missing .wfctl.yaml? run `wfctl registry list` or set WFCTL_DEBUG=1)", name)
}

normalized := normalizePluginName(name)
if debugRegistryLog {
fmt.Fprintf(os.Stderr, "[wfctl debug] FetchVersionIndex %q: %d source(s), normalized=%q\n",
name, len(m.sources), normalized)
}

var lastErr error
for _, src := range m.sources {
index, err := src.FetchVersionIndex(name)
if debugRegistryLog {
if err != nil {
fmt.Fprintf(os.Stderr, "[wfctl debug] %s (original %q index): %v\n", src.Name(), name, err)
} else {
fmt.Fprintf(os.Stderr, "[wfctl debug] %s (original %q index): found %d version(s)\n",
src.Name(), name, len(index.Versions))
}
}
if err == nil {
return index, src.Name(), nil
}
lastErr = err
}

if normalized != name {
for _, src := range m.sources {
index, err := src.FetchVersionIndex(normalized)
if debugRegistryLog {
if err != nil {
fmt.Fprintf(os.Stderr, "[wfctl debug] %s (normalized %q index): %v\n", src.Name(), normalized, err)
} else {
fmt.Fprintf(os.Stderr, "[wfctl debug] %s (normalized %q index): found %d version(s)\n",
src.Name(), normalized, len(index.Versions))
}
}
if err == nil {
return index, src.Name(), nil
}
lastErr = err
}
}

if lastErr != nil {
return nil, "", lastErr
}
return nil, "", fmt.Errorf("plugin %q compatibility index not found in any configured registry", name)
}

func (m *MultiRegistry) FetchManifestAndVersionIndex(name string) (*RegistryManifest, *PluginVersionIndex, string, error) {
if len(m.sources) == 0 {
return nil, nil, "", fmt.Errorf("plugin %q not found: no registry sources configured"+
" (missing .wfctl.yaml? run `wfctl registry list` or set WFCTL_DEBUG=1)", name)
}
normalized := normalizePluginName(name)
var lastErr error
for _, candidate := range []string{name, normalized} {
if candidate == "" {
continue
}
for _, src := range m.sources {
manifest, err := src.FetchManifest(candidate)
if err != nil {
lastErr = err
continue
}
index, idxErr := src.FetchVersionIndex(candidate)
if idxErr != nil {
return manifest, nil, src.Name(), idxErr
}
return manifest, index, src.Name(), nil
}
if candidate == normalized {
break
}
}
if lastErr != nil {
return nil, nil, "", lastErr
}
return nil, nil, "", fmt.Errorf("plugin %q not found in any configured registry", name)
}

// SearchPlugins searches all sources and returns deduplicated results.
// When the same plugin appears in multiple registries, the higher-priority source wins.
// The query is normalized (stripping "workflow-plugin-" prefix) before searching.
Expand Down
103 changes: 99 additions & 4 deletions cmd/wfctl/multi_registry_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,11 @@ import (
// ---------------------------------------------------------------------------

type mockRegistrySource struct {
name string
manifests map[string]*RegistryManifest
listErr error
fetchErr map[string]error
name string
manifests map[string]*RegistryManifest
versionIndexes map[string]*PluginVersionIndex
listErr error
fetchErr map[string]error
}

func (m *mockRegistrySource) Name() string { return m.name }
Expand Down Expand Up @@ -48,6 +49,19 @@ func (m *mockRegistrySource) FetchManifest(name string) (*RegistryManifest, erro
return manifest, nil
}

func (m *mockRegistrySource) FetchVersionIndex(name string) (*PluginVersionIndex, error) {
if m.versionIndexes != nil {
if index, ok := m.versionIndexes[name]; ok {
return index, nil
}
}
manifest, err := m.FetchManifest(name)
if err != nil {
return nil, err
}
return synthesizeVersionIndexFromManifest(manifest), nil
}

func (m *mockRegistrySource) SearchPlugins(query string) ([]PluginSearchResult, error) {
if m.listErr != nil {
return nil, m.listErr
Expand Down Expand Up @@ -127,6 +141,9 @@ func TestDefaultRegistryConfig(t *testing.T) {
if r.Priority != 0 {
t.Errorf("priority: got %d, want 0", r.Priority)
}
if r.CompatibilityEvidence.Trust != CompatibilityTrustFirstParty {
t.Errorf("default trust: got %q, want %q", r.CompatibilityEvidence.Trust, CompatibilityTrustFirstParty)
}
// Secondary fallback: static mirror (GitHub Pages CDN — lower priority).
fb := cfg.Registries[1]
if fb.Name != "static-mirror" {
Expand All @@ -141,6 +158,21 @@ func TestDefaultRegistryConfig(t *testing.T) {
if fb.Priority != 100 {
t.Errorf("fallback priority: got %d, want 100", fb.Priority)
}
if fb.CompatibilityEvidence.Trust != CompatibilityTrustFirstParty {
t.Errorf("static mirror trust: got %q, want %q", fb.CompatibilityEvidence.Trust, CompatibilityTrustFirstParty)
}
}

func TestRegistryCompatibilityTrustDefaults(t *testing.T) {
cfg := &RegistryConfig{Registries: []RegistrySourceConfig{{
Name: "community",
Type: "static",
URL: "https://example.test",
}}}
applyRegistryConfigDefaults(cfg)
if got := cfg.Registries[0].CompatibilityEvidence.Trust; got != CompatibilityTrustAdvisory {
t.Fatalf("user registry trust = %q, want %q", got, CompatibilityTrustAdvisory)
}
}

func TestLoadRegistryConfigFromFile(t *testing.T) {
Expand Down Expand Up @@ -403,6 +435,69 @@ func TestMultiRegistryFetchOriginalNameFirst(t *testing.T) {
}
}

func TestMultiRegistryFetchVersionIndex_UsesSameSourceAsManifest(t *testing.T) {
srcA := &mockRegistrySource{
name: "primary",
manifests: map[string]*RegistryManifest{
"shared-plugin": {Name: "shared-plugin", Version: "1.0.0"},
},
versionIndexes: map[string]*PluginVersionIndex{
"shared-plugin": {
Plugin: "shared-plugin",
Versions: []PluginVersionRecord{{Version: "v1.0.0"}},
},
},
}
srcB := &mockRegistrySource{
name: "secondary",
manifests: map[string]*RegistryManifest{
"shared-plugin": {Name: "shared-plugin", Version: "2.0.0"},
},
versionIndexes: map[string]*PluginVersionIndex{
"shared-plugin": {
Plugin: "shared-plugin",
Versions: []PluginVersionRecord{{Version: "v2.0.0"}},
},
},
}

mr := NewMultiRegistryFromSources(srcA, srcB)
index, source, err := mr.FetchVersionIndex("shared-plugin")
if err != nil {
t.Fatalf("FetchVersionIndex: %v", err)
}
if source != "primary" {
t.Fatalf("source = %q, want primary", source)
}
if got := index.Versions[0].Version; got != "v1.0.0" {
t.Fatalf("version index came from wrong source: got %q", got)
}
}

func TestMultiRegistryFetchVersionIndex_NormalizedFallback(t *testing.T) {
srcA := &mockRegistrySource{
name: "registry",
manifests: map[string]*RegistryManifest{
"auth": {Name: "auth", Version: "1.0.0"},
},
versionIndexes: map[string]*PluginVersionIndex{
"auth": {
Plugin: "auth",
Versions: []PluginVersionRecord{{Version: "v1.0.0"}},
},
},
}

mr := NewMultiRegistryFromSources(srcA)
index, _, err := mr.FetchVersionIndex("workflow-plugin-auth")
if err != nil {
t.Fatalf("FetchVersionIndex: %v", err)
}
if index.Plugin != "auth" {
t.Fatalf("plugin = %q, want auth", index.Plugin)
}
}

// TestMultiRegistryFetchNormalizedFallback verifies that when the full name is not
// found in any source, the normalized short name is used as a fallback. This allows
// users to omit the "workflow-plugin-" prefix in their config.
Expand Down
3 changes: 3 additions & 0 deletions cmd/wfctl/plugin.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ func runPlugin(args []string) error {
return runPluginRemove(args[1:])
case "validate":
return runPluginValidate(args[1:])
case "conformance":
return runPluginConformance(args[1:])
case "info":
return runPluginInfo(args[1:])
case "deps":
Expand All @@ -59,6 +61,7 @@ Subcommands:
update Update an installed plugin to its latest version
remove Uninstall a plugin (also removes from manifest + lockfile)
validate Validate a plugin manifest from the registry or a local file
conformance Run executable plugin/host conformance checks
info Show details about an installed plugin
deps List dependencies for a plugin

Expand Down
Loading
Loading