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
3 changes: 3 additions & 0 deletions cmd/wfctl/plugin.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ func runPlugin(args []string) error {
return runPluginInfo(args[1:])
case "deps":
return runPluginDeps(args[1:])
case "marketplace-verify":
return runPluginMarketplaceVerify(args[1:])
default:
return pluginUsage()
}
Expand All @@ -64,6 +66,7 @@ Subcommands:
conformance Run executable plugin/host conformance checks
info Show details about an installed plugin
deps List dependencies for a plugin
marketplace-verify Scan a GitHub org's wfctl.yaml files for plugin usage; suggests manifest status (verified | experimental)

Use -plugin-dir to specify a custom plugin directory (replaces deprecated -data-dir).
`)
Expand Down
135 changes: 135 additions & 0 deletions cmd/wfctl/plugin_marketplace_verify.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
package main

import (
"context"
"encoding/json"
"flag"
"fmt"
"os"
"os/exec"
"strings"
)

// runPluginMarketplaceVerify scans a GitHub org for merged main-branch wfctl.yaml files
// that reference the given plugin. Reports whether the plugin's registry
// status should be "verified" (>=1 active pin) or "experimental" (no pins).
//
// Backed by `gh api` (the official GitHub CLI) so the subcommand inherits the
// operator's existing GitHub auth and rate-limit budget. No new auth surface.
func runPluginMarketplaceVerify(args []string) error {
fs := flag.NewFlagSet("plugin verify", flag.ContinueOnError)
org := fs.String("org", "GoCodeAlone", "GitHub org to scan")
jsonOut := fs.Bool("json", false, "Output JSON instead of human-readable text")
fs.Usage = func() {
fmt.Fprintf(fs.Output(), `Usage: wfctl plugin verify [options] <plugin-name>
Comment on lines +20 to +24

Scan a GitHub org for merged main-branch wfctl.yaml files that pin the
plugin. Reports the suggested registry status:

- "verified" >=1 active pin in a main-branch wfctl.yaml
- "experimental" no active pins

Options:
`)
fs.PrintDefaults()
}
if err := fs.Parse(args); err != nil {
return err
}
if fs.NArg() < 1 {
fs.Usage()
return fmt.Errorf("plugin name is required")
}
pluginName := fs.Arg(0)

pins, err := searchOrgForPluginPins(context.Background(), *org, pluginName, ghAPICmd)
if err != nil {
return fmt.Errorf("search org: %w", err)
}

verdict := "experimental"
if len(pins) > 0 {
verdict = "verified"
}

if *jsonOut {
report := map[string]any{
"plugin": pluginName,
"org": *org,
"status": verdict,
"pin_count": len(pins),
"pinned_in": pins,
}
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(report)
}

fmt.Printf("Plugin: %s\n", pluginName)
fmt.Printf("Org: %s\n", *org)
fmt.Printf("Pins: %d\n", len(pins))
fmt.Printf("Verdict: %s\n", verdict)
if len(pins) > 0 {
fmt.Println("Pinned in:")
for _, p := range pins {
fmt.Printf(" - %s\n", p)
}
}
if verdict == "experimental" {
fmt.Println("\nNo active main-branch pins found. Manifest status should be 'experimental'.")
} else {
fmt.Println("\nActive pins found. Manifest status should be 'verified'.")
}
return nil
}

// ghAPICmd is the indirection point so tests can inject a fake gh binary.
// Default is the real `gh api` CLI.
var ghAPICmd = func(ctx context.Context, endpoint string) ([]byte, error) {
cmd := exec.CommandContext(ctx, "gh", "api", endpoint)

Check failure on line 89 in cmd/wfctl/plugin_marketplace_verify.go

View workflow job for this annotation

GitHub Actions / Lint

G204: Subprocess launched with variable (gosec)
return cmd.Output()
}

// searchOrgForPluginPins queries the GitHub code-search API for `name:
// <plugin>` occurrences inside wfctl.yaml files within the org. Returns a
// list of repo+path strings.
func searchOrgForPluginPins(ctx context.Context, org, plugin string, ghAPI func(context.Context, string) ([]byte, error)) ([]string, error) {
query := fmt.Sprintf(`filename:wfctl.yaml org:%s "name: workflow-plugin-%s"`, org, plugin)
Comment on lines +93 to +97
endpoint := fmt.Sprintf("search/code?q=%s&per_page=100", urlQueryEscape(query))

body, err := ghAPI(ctx, endpoint)
if err != nil {
return nil, fmt.Errorf("gh api search: %w", err)
}

var result struct {
TotalCount int `json:"total_count"`
Items []struct {
Path string `json:"path"`
Repository struct {
FullName string `json:"full_name"`
} `json:"repository"`
} `json:"items"`
}
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("decode search response: %w", err)
}

pins := make([]string, 0, len(result.Items))
for _, item := range result.Items {
pins = append(pins, fmt.Sprintf("%s/%s", item.Repository.FullName, item.Path))
}
return pins, nil
}

// urlQueryEscape minimal escape for the GitHub code-search query string.
// We rely on `gh api` to handle most encoding; only spaces, quotes, and
// colons need percent-escaping for the endpoint URL.
func urlQueryEscape(q string) string {
r := strings.NewReplacer(
" ", "%20",
`"`, "%22",
":", "%3A",
)
return r.Replace(q)
}
101 changes: 101 additions & 0 deletions cmd/wfctl/plugin_marketplace_verify_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
package main

import (
"context"
"errors"
"strings"
"testing"
)

func TestSearchOrgForPluginPins_Verified(t *testing.T) {
fake := func(ctx context.Context, endpoint string) ([]byte, error) {
return []byte(`{
"total_count": 2,
"items": [
{"path": "wfctl.yaml", "repository": {"full_name": "GoCodeAlone/buymywishlist"}},
{"path": "wfctl.yaml", "repository": {"full_name": "GoCodeAlone/core-dump"}}
]
}`), nil
}
pins, err := searchOrgForPluginPins(context.Background(), "GoCodeAlone", "digitalocean", fake)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(pins) != 2 {
t.Fatalf("expected 2 pins, got %d: %v", len(pins), pins)
}
if pins[0] != "GoCodeAlone/buymywishlist/wfctl.yaml" {
t.Errorf("pin[0]=%q want GoCodeAlone/buymywishlist/wfctl.yaml", pins[0])
}
}

func TestSearchOrgForPluginPins_Experimental(t *testing.T) {
fake := func(ctx context.Context, endpoint string) ([]byte, error) {
return []byte(`{"total_count": 0, "items": []}`), nil
}
pins, err := searchOrgForPluginPins(context.Background(), "GoCodeAlone", "aws", fake)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(pins) != 0 {
t.Fatalf("expected 0 pins, got %d: %v", len(pins), pins)
}
}

func TestSearchOrgForPluginPins_GHAPIError(t *testing.T) {
fake := func(ctx context.Context, endpoint string) ([]byte, error) {
return nil, errors.New("rate limit")
}
_, err := searchOrgForPluginPins(context.Background(), "GoCodeAlone", "aws", fake)
if err == nil {
t.Fatal("expected error, got nil")
}
}

func TestSearchOrgForPluginPins_BadJSON(t *testing.T) {
fake := func(ctx context.Context, endpoint string) ([]byte, error) {
return []byte(`not json`), nil
}
_, err := searchOrgForPluginPins(context.Background(), "GoCodeAlone", "aws", fake)
if err == nil {
t.Fatal("expected decode error, got nil")
}
}

func TestUrlQueryEscape(t *testing.T) {
cases := []struct {
in, want string
}{
{"a b c", "a%20b%20c"},
{`"hello"`, "%22hello%22"},
{"org:GoCodeAlone", "org%3AGoCodeAlone"},
{`filename:wfctl.yaml org:X "name: workflow-plugin-aws"`,
`filename%3Awfctl.yaml%20org%3AX%20%22name%3A%20workflow-plugin-aws%22`},
}
for _, tc := range cases {
got := urlQueryEscape(tc.in)
if got != tc.want {
t.Errorf("escape(%q)=%q want %q", tc.in, got, tc.want)
}
}
}

func TestSearchEndpoint_BuildsExpectedURL(t *testing.T) {
var captured string
fake := func(ctx context.Context, endpoint string) ([]byte, error) {
captured = endpoint
return []byte(`{"items":[]}`), nil
}
_, _ = searchOrgForPluginPins(context.Background(), "GoCodeAlone", "twilio", fake)
wantSub := "filename%3Awfctl.yaml%20org%3AGoCodeAlone"
wantPlugin := "workflow-plugin-twilio"
if captured == "" {
t.Fatal("endpoint not captured")
}
if !strings.Contains(captured, wantSub) {
t.Errorf("endpoint missing %q: %s", wantSub, captured)
}
if !strings.Contains(captured, wantPlugin) {
t.Errorf("endpoint missing plugin name %q: %s", wantPlugin, captured)
}
}
Loading