From 9f7c9efd81fe62c6baece29a8d4d3adfb5a889af Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 1 May 2026 06:12:29 +0000 Subject: [PATCH 1/9] Initial plan From 3f7ac1520396399f6abace6beed73f912eb00968 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 1 May 2026 12:26:04 +0000 Subject: [PATCH 2/9] feat: add strict gRPC contract descriptors and wfctl CI validation - Add strict contract descriptors for all 12 advertised types (1 module, 10 steps, 1 trigger) to plugin.json with mode:strict and proto message names following the discord.v1.* naming convention - Add download entries for v0.1.1 release archives (required for external plugin manifest validation) - Add wfctl-strict-contracts job to CI workflow running wfctl@v0.20.1 plugin validate --file plugin.json --strict-contracts Validation output: go test ./... -race: PASS go vet ./...: PASS go mod tidy: no diff wfctl plugin validate --file plugin.json --strict-contracts: OK" Agent-Logs-Url: https://github.com/GoCodeAlone/workflow-plugin-discord/sessions/40259a89-1f17-48a2-881d-3c9e45a1b4fc Co-authored-by: intel352 <77607+intel352@users.noreply.github.com> --- .github/workflows/ci.yml | 9 ++++ plugin.json | 109 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 117 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8204db7..6667067 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,3 +14,12 @@ jobs: go-version-file: go.mod - run: go build ./... - run: go test ./... -v -race -count=1 + wfctl-strict-contracts: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - name: Validate strict plugin contracts + run: go run github.com/GoCodeAlone/workflow/cmd/wfctl@v0.20.1 plugin validate --file plugin.json --strict-contracts diff --git a/plugin.json b/plugin.json index 04220ca..bb5356c 100644 --- a/plugin.json +++ b/plugin.json @@ -25,5 +25,112 @@ "step.discord_voice_play" ], "triggerTypes": ["trigger.discord"] - } + }, + "contracts": [ + { + "kind": "module", + "type": "discord.provider", + "mode": "strict", + "config": "discord.v1.ProviderConfig" + }, + { + "kind": "step", + "type": "step.discord_send_message", + "mode": "strict", + "config": "discord.v1.SendMessageConfig", + "output": "discord.v1.SendMessageOutput" + }, + { + "kind": "step", + "type": "step.discord_send_embed", + "mode": "strict", + "config": "discord.v1.SendEmbedConfig", + "output": "discord.v1.SendEmbedOutput" + }, + { + "kind": "step", + "type": "step.discord_edit_message", + "mode": "strict", + "config": "discord.v1.EditMessageConfig", + "output": "discord.v1.EditMessageOutput" + }, + { + "kind": "step", + "type": "step.discord_delete_message", + "mode": "strict", + "config": "discord.v1.DeleteMessageConfig", + "output": "discord.v1.DeleteMessageOutput" + }, + { + "kind": "step", + "type": "step.discord_add_reaction", + "mode": "strict", + "config": "discord.v1.AddReactionConfig", + "output": "discord.v1.AddReactionOutput" + }, + { + "kind": "step", + "type": "step.discord_upload_file", + "mode": "strict", + "config": "discord.v1.UploadFileConfig", + "output": "discord.v1.UploadFileOutput" + }, + { + "kind": "step", + "type": "step.discord_create_thread", + "mode": "strict", + "config": "discord.v1.CreateThreadConfig", + "output": "discord.v1.CreateThreadOutput" + }, + { + "kind": "step", + "type": "step.discord_voice_join", + "mode": "strict", + "config": "discord.v1.VoiceJoinConfig", + "output": "discord.v1.VoiceJoinOutput" + }, + { + "kind": "step", + "type": "step.discord_voice_leave", + "mode": "strict", + "config": "discord.v1.VoiceLeaveConfig", + "output": "discord.v1.VoiceLeaveOutput" + }, + { + "kind": "step", + "type": "step.discord_voice_play", + "mode": "strict", + "config": "discord.v1.VoicePlayConfig", + "output": "discord.v1.VoicePlayOutput" + }, + { + "kind": "trigger", + "type": "trigger.discord", + "mode": "strict", + "config": "discord.v1.TriggerConfig", + "output": "discord.v1.TriggerPayload" + } + ], + "downloads": [ + { + "os": "linux", + "arch": "amd64", + "url": "https://github.com/GoCodeAlone/workflow-plugin-discord/releases/download/v0.1.1/workflow-plugin-discord-linux-amd64.tar.gz" + }, + { + "os": "linux", + "arch": "arm64", + "url": "https://github.com/GoCodeAlone/workflow-plugin-discord/releases/download/v0.1.1/workflow-plugin-discord-linux-arm64.tar.gz" + }, + { + "os": "darwin", + "arch": "amd64", + "url": "https://github.com/GoCodeAlone/workflow-plugin-discord/releases/download/v0.1.1/workflow-plugin-discord-darwin-amd64.tar.gz" + }, + { + "os": "darwin", + "arch": "arm64", + "url": "https://github.com/GoCodeAlone/workflow-plugin-discord/releases/download/v0.1.1/workflow-plugin-discord-darwin-arm64.tar.gz" + } + ] } From 40d18a7193151c73c0b11e92dd1ff88f043d5d40 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 1 May 2026 12:26:37 +0000 Subject: [PATCH 3/9] fix: add explicit permissions to wfctl-strict-contracts CI job Agent-Logs-Url: https://github.com/GoCodeAlone/workflow-plugin-discord/sessions/40259a89-1f17-48a2-881d-3c9e45a1b4fc Co-authored-by: intel352 <77607+intel352@users.noreply.github.com> --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6667067..fbdf513 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,6 +16,8 @@ jobs: - run: go test ./... -v -race -count=1 wfctl-strict-contracts: runs-on: ubuntu-latest + permissions: + contents: read steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 From 127d3e1ef45da98439376fcc90951f01bfdd8585 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 2 May 2026 14:01:01 +0000 Subject: [PATCH 4/9] fix: align plugin.json version to 0.1.1 and update goreleaser to rewrite download URLs on release - Bump plugin.json version from 0.1.0 to 0.1.1 so version field matches the existing v0.1.1 download URLs (resolves reviewer mismatch finding) - Add a second goreleaser before-hook sed that rewrites the version tag inside all download URLs to the current release tag on every release, preventing stale URLs in future release archives Agent-Logs-Url: https://github.com/GoCodeAlone/workflow-plugin-discord/sessions/edf40a47-9e1e-4c09-8ee8-421d99ec51fb Co-authored-by: intel352 <77607+intel352@users.noreply.github.com> --- .goreleaser.yaml | 1 + plugin.json | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.goreleaser.yaml b/.goreleaser.yaml index d05e744..baa8bca 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -4,6 +4,7 @@ before: hooks: - "cp plugin.json plugin.json.orig" - "sed -i.bak 's/\"version\": \".*\"/\"version\": \"{{ .Version }}\"/' plugin.json && rm -f plugin.json.bak" + - "sed -i.bak 's|/releases/download/v[^/]*/|/releases/download/v{{ .Version }}/|g' plugin.json && rm -f plugin.json.bak" builds: - main: ./cmd/{{ .ProjectName }} diff --git a/plugin.json b/plugin.json index bb5356c..b9f84d0 100644 --- a/plugin.json +++ b/plugin.json @@ -1,6 +1,6 @@ { "name": "discord", - "version": "0.1.0", + "version": "0.1.1", "author": "GoCodeAlone", "description": "Discord messaging, bots, and voice", "type": "external", From 3b48278455ca4ae2699c2d6ba5592560759836a2 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sat, 2 May 2026 15:50:19 -0400 Subject: [PATCH 5/9] fix: validate strict release manifest parity --- .goreleaser.yaml | 1 + go.mod | 2 +- internal/plugin_test.go | 182 +++++++++++++++++++++++++++++++++++++++- plugin.json | 10 +-- 4 files changed, 186 insertions(+), 9 deletions(-) diff --git a/.goreleaser.yaml b/.goreleaser.yaml index baa8bca..83a281b 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -5,6 +5,7 @@ before: - "cp plugin.json plugin.json.orig" - "sed -i.bak 's/\"version\": \".*\"/\"version\": \"{{ .Version }}\"/' plugin.json && rm -f plugin.json.bak" - "sed -i.bak 's|/releases/download/v[^/]*/|/releases/download/v{{ .Version }}/|g' plugin.json && rm -f plugin.json.bak" + - "go run github.com/GoCodeAlone/workflow/cmd/wfctl@v0.20.1 plugin validate --file plugin.json --strict-contracts" builds: - main: ./cmd/{{ .ProjectName }} diff --git a/go.mod b/go.mod index 13be262..a7b6a3a 100644 --- a/go.mod +++ b/go.mod @@ -220,7 +220,7 @@ require ( google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect google.golang.org/grpc v1.79.3 // indirect google.golang.org/protobuf v1.36.11 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect + gopkg.in/yaml.v3 v3.0.1 modernc.org/libc v1.67.6 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect diff --git a/internal/plugin_test.go b/internal/plugin_test.go index a40c348..e0e9fd4 100644 --- a/internal/plugin_test.go +++ b/internal/plugin_test.go @@ -2,7 +2,15 @@ package internal import ( "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "runtime" + "strings" "testing" + + "gopkg.in/yaml.v3" ) func TestManifest(t *testing.T) { @@ -103,10 +111,85 @@ func TestCreateStepAllTypes(t *testing.T) { } func TestStepTypesMatchManifest(t *testing.T) { - // Verify the step count matches plugin.json capabilities p := New() - if len(p.StepTypes()) != 10 { - t.Errorf("expected 10 step types to match plugin.json, got %d", len(p.StepTypes())) + manifest := loadPluginManifest(t) + if got, want := stringSet(p.StepTypes()), stringSet(manifest.Capabilities.StepTypes); !setsEqual(got, want) { + t.Fatalf("step types = %v, want manifest %v", got, want) + } +} + +func TestPluginContractsMatchRuntimeTypes(t *testing.T) { + p := New() + manifest := loadPluginManifest(t) + + want := map[string]bool{} + for _, moduleType := range p.ModuleTypes() { + want["module:"+moduleType] = true + } + for _, stepType := range p.StepTypes() { + want["step:"+stepType] = true + } + for _, triggerType := range p.TriggerTypes() { + want["trigger:"+triggerType] = true + } + + got := map[string]bool{} + for _, contract := range manifest.Contracts { + key := contract.Kind + ":" + contract.Type + got[key] = true + if contract.Mode != "strict" { + t.Fatalf("%s mode = %q, want strict", key, contract.Mode) + } + if contract.Config == "" { + t.Fatalf("%s missing config message", key) + } + } + if !setsEqual(got, want) { + t.Fatalf("contracts = %v, want runtime types %v", got, want) + } +} + +func TestPluginDownloadsMatchGoReleaserMatrix(t *testing.T) { + manifest := loadPluginManifest(t) + release := loadGoReleaserConfig(t) + if len(release.Builds) != 1 { + t.Fatalf("goreleaser builds = %d, want 1", len(release.Builds)) + } + build := release.Builds[0] + + want := map[string]string{} + for _, goos := range build.Goos { + for _, goarch := range build.Goarch { + key := goos + "/" + goarch + want[key] = fmt.Sprintf("https://github.com/GoCodeAlone/workflow-plugin-discord/releases/download/v%s/workflow-plugin-discord-%s-%s.tar.gz", manifest.Version, goos, goarch) + } + } + + got := map[string]string{} + for _, download := range manifest.Downloads { + got[download.OS+"/"+download.Arch] = download.URL + } + if !setsEqual(stringSetFromMap(got), stringSetFromMap(want)) { + t.Fatalf("download matrix = %v, want %v", got, want) + } + for key, wantURL := range want { + if gotURL := got[key]; gotURL != wantURL { + t.Fatalf("download %s = %q, want %q", key, gotURL, wantURL) + } + } +} + +func TestGoReleaserValidatesRewrittenPluginManifest(t *testing.T) { + config := loadGoReleaserConfig(t) + hooks := strings.Join(config.Before.Hooks, "\n") + for _, want := range []string{ + `sed -i.bak 's/"version": ".*"/"version": "{{ .Version }}"/' plugin.json`, + `sed -i.bak 's|/releases/download/v[^/]*/|/releases/download/v{{ .Version }}/|g' plugin.json`, + `wfctl@v0.20.1 plugin validate --file plugin.json --strict-contracts`, + } { + if !strings.Contains(hooks, want) { + t.Fatalf(".goreleaser.yaml missing %q", want) + } } } @@ -118,6 +201,99 @@ func TestCreateTriggerMissingToken(t *testing.T) { } } +type pluginManifest struct { + Version string `json:"version"` + Capabilities struct { + ModuleTypes []string `json:"moduleTypes"` + StepTypes []string `json:"stepTypes"` + TriggerTypes []string `json:"triggerTypes"` + } `json:"capabilities"` + Contracts []struct { + Kind string `json:"kind"` + Type string `json:"type"` + Mode string `json:"mode"` + Config string `json:"config"` + } `json:"contracts"` + Downloads []struct { + OS string `json:"os"` + Arch string `json:"arch"` + URL string `json:"url"` + } `json:"downloads"` +} + +type goReleaserConfig struct { + Before struct { + Hooks []string `yaml:"hooks"` + } `yaml:"before"` + Builds []struct { + Goos []string `yaml:"goos"` + Goarch []string `yaml:"goarch"` + } `yaml:"builds"` +} + +func loadPluginManifest(t *testing.T) pluginManifest { + t.Helper() + data, err := os.ReadFile(filepath.Join(repoRoot(t), "plugin.json")) + if err != nil { + t.Fatalf("read plugin.json: %v", err) + } + var manifest pluginManifest + if err := json.Unmarshal(data, &manifest); err != nil { + t.Fatalf("parse plugin.json: %v", err) + } + return manifest +} + +func loadGoReleaserConfig(t *testing.T) goReleaserConfig { + t.Helper() + data, err := os.ReadFile(filepath.Join(repoRoot(t), ".goreleaser.yaml")) + if err != nil { + t.Fatalf("read .goreleaser.yaml: %v", err) + } + var config goReleaserConfig + if err := yaml.Unmarshal(data, &config); err != nil { + t.Fatalf("parse .goreleaser.yaml: %v", err) + } + return config +} + +func repoRoot(t *testing.T) string { + t.Helper() + _, file, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("runtime.Caller failed") + } + return filepath.Dir(filepath.Dir(file)) +} + +func stringSet(values []string) map[string]bool { + set := make(map[string]bool, len(values)) + for _, value := range values { + set[value] = true + } + return set +} + +func stringSetFromMap(values map[string]string) map[string]bool { + set := make(map[string]bool, len(values)) + for value := range values { + set[value] = true + } + return set +} + +func setsEqual(left, right map[string]bool) bool { + if len(left) != len(right) { + return false + } + for key := range left { + if !right[key] { + return false + } + } + return true +} + // withTestProvider registers a fake discordProvider for Execute-level tests. func withTestProvider(t *testing.T, name string) { t.Helper() diff --git a/plugin.json b/plugin.json index b9f84d0..2fb2286 100644 --- a/plugin.json +++ b/plugin.json @@ -1,6 +1,6 @@ { "name": "discord", - "version": "0.1.1", + "version": "0.1.0", "author": "GoCodeAlone", "description": "Discord messaging, bots, and voice", "type": "external", @@ -115,22 +115,22 @@ { "os": "linux", "arch": "amd64", - "url": "https://github.com/GoCodeAlone/workflow-plugin-discord/releases/download/v0.1.1/workflow-plugin-discord-linux-amd64.tar.gz" + "url": "https://github.com/GoCodeAlone/workflow-plugin-discord/releases/download/v0.1.0/workflow-plugin-discord-linux-amd64.tar.gz" }, { "os": "linux", "arch": "arm64", - "url": "https://github.com/GoCodeAlone/workflow-plugin-discord/releases/download/v0.1.1/workflow-plugin-discord-linux-arm64.tar.gz" + "url": "https://github.com/GoCodeAlone/workflow-plugin-discord/releases/download/v0.1.0/workflow-plugin-discord-linux-arm64.tar.gz" }, { "os": "darwin", "arch": "amd64", - "url": "https://github.com/GoCodeAlone/workflow-plugin-discord/releases/download/v0.1.1/workflow-plugin-discord-darwin-amd64.tar.gz" + "url": "https://github.com/GoCodeAlone/workflow-plugin-discord/releases/download/v0.1.0/workflow-plugin-discord-darwin-amd64.tar.gz" }, { "os": "darwin", "arch": "arm64", - "url": "https://github.com/GoCodeAlone/workflow-plugin-discord/releases/download/v0.1.1/workflow-plugin-discord-darwin-arm64.tar.gz" + "url": "https://github.com/GoCodeAlone/workflow-plugin-discord/releases/download/v0.1.0/workflow-plugin-discord-darwin-arm64.tar.gz" } ] } From 1fa61fc17807872ef56821e4c3bf1effd59ea032 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sat, 2 May 2026 15:56:38 -0400 Subject: [PATCH 6/9] test: assert discord contract output descriptors --- internal/plugin_test.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/internal/plugin_test.go b/internal/plugin_test.go index e0e9fd4..53335d0 100644 --- a/internal/plugin_test.go +++ b/internal/plugin_test.go @@ -143,6 +143,9 @@ func TestPluginContractsMatchRuntimeTypes(t *testing.T) { if contract.Config == "" { t.Fatalf("%s missing config message", key) } + if contract.Kind != "module" && contract.Output == "" { + t.Fatalf("%s missing output message", key) + } } if !setsEqual(got, want) { t.Fatalf("contracts = %v, want runtime types %v", got, want) @@ -213,6 +216,7 @@ type pluginManifest struct { Type string `json:"type"` Mode string `json:"mode"` Config string `json:"config"` + Output string `json:"output"` } `json:"contracts"` Downloads []struct { OS string `json:"os"` From 1ad2952a9d4b0f055c2c5f00ac4f62fd6d051526 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sat, 2 May 2026 19:27:55 -0400 Subject: [PATCH 7/9] test: tighten manifest contract coverage --- internal/plugin_test.go | 168 +++++++++++++++++++++++++++++++++++----- 1 file changed, 150 insertions(+), 18 deletions(-) diff --git a/internal/plugin_test.go b/internal/plugin_test.go index 53335d0..cd5e49e 100644 --- a/internal/plugin_test.go +++ b/internal/plugin_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "os" + "os/exec" "path/filepath" "runtime" "strings" @@ -110,11 +111,26 @@ func TestCreateStepAllTypes(t *testing.T) { } } -func TestStepTypesMatchManifest(t *testing.T) { +func TestRuntimeTypesMatchManifestCapabilities(t *testing.T) { p := New() manifest := loadPluginManifest(t) - if got, want := stringSet(p.StepTypes()), stringSet(manifest.Capabilities.StepTypes); !setsEqual(got, want) { - t.Fatalf("step types = %v, want manifest %v", got, want) + + tests := []struct { + name string + got []string + want []string + }{ + {"module types", p.ModuleTypes(), manifest.Capabilities.ModuleTypes}, + {"step types", p.StepTypes(), manifest.Capabilities.StepTypes}, + {"trigger types", p.TriggerTypes(), manifest.Capabilities.TriggerTypes}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got, want := stringSet(tt.got), stringSet(tt.want); !setsEqual(got, want) { + t.Fatalf("%s = %v, want manifest %v", tt.name, got, want) + } + }) } } @@ -132,6 +148,23 @@ func TestPluginContractsMatchRuntimeTypes(t *testing.T) { for _, triggerType := range p.TriggerTypes() { want["trigger:"+triggerType] = true } + wantDescriptors := map[string]struct { + config string + output string + }{ + "module:discord.provider": {"discord.v1.ProviderConfig", ""}, + "step:step.discord_send_message": {"discord.v1.SendMessageConfig", "discord.v1.SendMessageOutput"}, + "step:step.discord_send_embed": {"discord.v1.SendEmbedConfig", "discord.v1.SendEmbedOutput"}, + "step:step.discord_edit_message": {"discord.v1.EditMessageConfig", "discord.v1.EditMessageOutput"}, + "step:step.discord_delete_message": {"discord.v1.DeleteMessageConfig", "discord.v1.DeleteMessageOutput"}, + "step:step.discord_add_reaction": {"discord.v1.AddReactionConfig", "discord.v1.AddReactionOutput"}, + "step:step.discord_upload_file": {"discord.v1.UploadFileConfig", "discord.v1.UploadFileOutput"}, + "step:step.discord_create_thread": {"discord.v1.CreateThreadConfig", "discord.v1.CreateThreadOutput"}, + "step:step.discord_voice_join": {"discord.v1.VoiceJoinConfig", "discord.v1.VoiceJoinOutput"}, + "step:step.discord_voice_leave": {"discord.v1.VoiceLeaveConfig", "discord.v1.VoiceLeaveOutput"}, + "step:step.discord_voice_play": {"discord.v1.VoicePlayConfig", "discord.v1.VoicePlayOutput"}, + "trigger:trigger.discord": {"discord.v1.TriggerConfig", "discord.v1.TriggerPayload"}, + } got := map[string]bool{} for _, contract := range manifest.Contracts { @@ -140,11 +173,15 @@ func TestPluginContractsMatchRuntimeTypes(t *testing.T) { if contract.Mode != "strict" { t.Fatalf("%s mode = %q, want strict", key, contract.Mode) } - if contract.Config == "" { - t.Fatalf("%s missing config message", key) + wantDescriptor, ok := wantDescriptors[key] + if !ok { + t.Fatalf("%s has no expected descriptor", key) + } + if contract.Config != wantDescriptor.config { + t.Fatalf("%s config = %q, want %q", key, contract.Config, wantDescriptor.config) } - if contract.Kind != "module" && contract.Output == "" { - t.Fatalf("%s missing output message", key) + if contract.Output != wantDescriptor.output { + t.Fatalf("%s output = %q, want %q", key, contract.Output, wantDescriptor.output) } } if !setsEqual(got, want) { @@ -183,17 +220,89 @@ func TestPluginDownloadsMatchGoReleaserMatrix(t *testing.T) { } func TestGoReleaserValidatesRewrittenPluginManifest(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("GoReleaser hooks are shell commands") + } + config := loadGoReleaserConfig(t) - hooks := strings.Join(config.Before.Hooks, "\n") - for _, want := range []string{ - `sed -i.bak 's/"version": ".*"/"version": "{{ .Version }}"/' plugin.json`, - `sed -i.bak 's|/releases/download/v[^/]*/|/releases/download/v{{ .Version }}/|g' plugin.json`, - `wfctl@v0.20.1 plugin validate --file plugin.json --strict-contracts`, - } { - if !strings.Contains(hooks, want) { - t.Fatalf(".goreleaser.yaml missing %q", want) + if len(config.Before.Hooks) == 0 { + t.Fatal(".goreleaser.yaml must define before hooks") + } + + releaseVersion := "9.8.7" + tmp := t.TempDir() + copyFile(t, filepath.Join(repoRoot(t), "plugin.json"), filepath.Join(tmp, "plugin.json")) + binDir := t.TempDir() + validationLog := filepath.Join(tmp, "validation.log") + writeExecutable(t, filepath.Join(binDir, "go"), `#!/bin/sh +if [ "$1" = "run" ]; then + shift + shift + exec wfctl "$@" +fi +echo "unexpected go invocation: $*" >&2 +exit 42 +`) + writeExecutable(t, filepath.Join(binDir, "wfctl"), `#!/bin/sh +file="" +strict=0 +prev="" +for arg in "$@"; do + if [ "$prev" = "--file" ]; then + file="$arg" + fi + if [ "$arg" = "--strict-contracts" ]; then + strict=1 + fi + prev="$arg" +done +if [ "$1" != "plugin" ] || [ "$2" != "validate" ]; then + echo "unexpected wfctl invocation: $*" >&2 + exit 43 +fi +if [ "$strict" != "1" ]; then + echo "missing --strict-contracts" >&2 + exit 44 +fi +if [ -z "$file" ]; then + echo "missing --file" >&2 + exit 45 +fi +grep -q "\"version\": \"$RELEASE_VERSION\"" "$file" || exit 46 +grep -q "/releases/download/v$RELEASE_VERSION/" "$file" || exit 47 +if grep -q "/releases/download/v0.1.0/" "$file"; then + exit 48 +fi +grep -q "\"mode\": \"strict\"" "$file" || exit 49 +printf 'validated %s\n' "$file" >> "$VALIDATION_LOG" +`) + + for _, hook := range config.Before.Hooks { + cmd := exec.CommandContext(context.Background(), "sh", "-c", strings.ReplaceAll(hook, "{{ .Version }}", releaseVersion)) + cmd.Dir = tmp + cmd.Env = append(os.Environ(), + "PATH="+binDir+string(os.PathListSeparator)+os.Getenv("PATH"), + "RELEASE_VERSION="+releaseVersion, + "VALIDATION_LOG="+validationLog, + ) + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("run GoReleaser hook %q: %v\n%s", hook, err, output) + } + } + + manifest := loadPluginManifestFrom(t, filepath.Join(tmp, "plugin.json")) + if manifest.Version != releaseVersion { + t.Fatalf("manifest version = %q, want %q", manifest.Version, releaseVersion) + } + for _, download := range manifest.Downloads { + wantPrefix := fmt.Sprintf("https://github.com/GoCodeAlone/workflow-plugin-discord/releases/download/v%s/", releaseVersion) + if !strings.HasPrefix(download.URL, wantPrefix) { + t.Fatalf("download URL %q does not use release version %s", download.URL, releaseVersion) } } + if data, err := os.ReadFile(validationLog); err != nil || !strings.Contains(string(data), "validated plugin.json") { + t.Fatalf("strict validation was not invoked; log=%q err=%v", data, err) + } } func TestCreateTriggerMissingToken(t *testing.T) { @@ -237,17 +346,40 @@ type goReleaserConfig struct { func loadPluginManifest(t *testing.T) pluginManifest { t.Helper() - data, err := os.ReadFile(filepath.Join(repoRoot(t), "plugin.json")) + return loadPluginManifestFrom(t, filepath.Join(repoRoot(t), "plugin.json")) +} + +func loadPluginManifestFrom(t *testing.T, path string) pluginManifest { + t.Helper() + data, err := os.ReadFile(path) if err != nil { - t.Fatalf("read plugin.json: %v", err) + t.Fatalf("read %s: %v", path, err) } var manifest pluginManifest if err := json.Unmarshal(data, &manifest); err != nil { - t.Fatalf("parse plugin.json: %v", err) + t.Fatalf("parse %s: %v", path, err) } return manifest } +func copyFile(t *testing.T, src, dst string) { + t.Helper() + data, err := os.ReadFile(src) + if err != nil { + t.Fatalf("read %s: %v", src, err) + } + if err := os.WriteFile(dst, data, 0o644); err != nil { + t.Fatalf("write %s: %v", dst, err) + } +} + +func writeExecutable(t *testing.T, path, content string) { + t.Helper() + if err := os.WriteFile(path, []byte(content), 0o755); err != nil { + t.Fatalf("write %s: %v", path, err) + } +} + func loadGoReleaserConfig(t *testing.T) goReleaserConfig { t.Helper() data, err := os.ReadFile(filepath.Join(repoRoot(t), ".goreleaser.yaml")) From 7552650bcb89b4b3738a3fc658aa73b159adfe46 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sat, 2 May 2026 19:40:15 -0400 Subject: [PATCH 8/9] fix: derive contracts from runtime registrations --- .goreleaser.yaml | 12 +-- internal/plugin.go | 164 +++++++++++++++++++++++++++++----------- internal/plugin_test.go | 31 ++++---- plugin.json | 10 +-- 4 files changed, 146 insertions(+), 71 deletions(-) diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 83a281b..3d0adc5 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -2,10 +2,11 @@ version: 2 before: hooks: - - "cp plugin.json plugin.json.orig" - - "sed -i.bak 's/\"version\": \".*\"/\"version\": \"{{ .Version }}\"/' plugin.json && rm -f plugin.json.bak" - - "sed -i.bak 's|/releases/download/v[^/]*/|/releases/download/v{{ .Version }}/|g' plugin.json && rm -f plugin.json.bak" - - "go run github.com/GoCodeAlone/workflow/cmd/wfctl@v0.20.1 plugin validate --file plugin.json --strict-contracts" + - "mkdir -p dist" + - "cp plugin.json dist/plugin.json" + - "sed -i.bak 's/\"version\": \".*\"/\"version\": \"{{ .Version }}\"/' dist/plugin.json && rm -f dist/plugin.json.bak" + - "sed -i.bak 's|/releases/download/v[^/]*/|/releases/download/v{{ .Version }}/|g' dist/plugin.json && rm -f dist/plugin.json.bak" + - "go run github.com/GoCodeAlone/workflow/cmd/wfctl@v0.20.1 plugin validate --file dist/plugin.json --strict-contracts" builds: - main: ./cmd/{{ .ProjectName }} @@ -25,7 +26,8 @@ archives: - formats: [tar.gz] name_template: "{{ .ProjectName }}-{{ .Os }}-{{ .Arch }}" files: - - plugin.json + - src: dist/plugin.json + dst: plugin.json checksum: name_template: checksums.txt diff --git a/internal/plugin.go b/internal/plugin.go index ac1b498..893b5a7 100644 --- a/internal/plugin.go +++ b/internal/plugin.go @@ -14,6 +14,67 @@ var Version = "0.0.0" type discordPlugin struct{} +type contractDescriptor struct { + Kind string + Type string + Config string + Output string +} + +type moduleRegistration struct { + typeName string + config string + create func(name string, config map[string]any) (sdk.ModuleInstance, error) +} + +type stepRegistration struct { + typeName string + config string + output string + create func() sdk.StepInstance +} + +type triggerRegistration struct { + typeName string + config string + output string + create func(config map[string]any, cb sdk.TriggerCallback) (sdk.TriggerInstance, error) +} + +var discordModuleRegistrations = []moduleRegistration{ + { + typeName: "discord.provider", + config: "discord.v1.ProviderConfig", + create: func(name string, config map[string]any) (sdk.ModuleInstance, error) { + return newDiscordProvider(name, config) + }, + }, +} + +var discordStepRegistrations = []stepRegistration{ + {"step.discord_send_message", "discord.v1.SendMessageConfig", "discord.v1.SendMessageOutput", func() sdk.StepInstance { return &sendMessageStep{} }}, + {"step.discord_send_embed", "discord.v1.SendEmbedConfig", "discord.v1.SendEmbedOutput", func() sdk.StepInstance { return &sendEmbedStep{} }}, + {"step.discord_edit_message", "discord.v1.EditMessageConfig", "discord.v1.EditMessageOutput", func() sdk.StepInstance { return &editMessageStep{} }}, + {"step.discord_delete_message", "discord.v1.DeleteMessageConfig", "discord.v1.DeleteMessageOutput", func() sdk.StepInstance { return &deleteMessageStep{} }}, + {"step.discord_add_reaction", "discord.v1.AddReactionConfig", "discord.v1.AddReactionOutput", func() sdk.StepInstance { return &addReactionStep{} }}, + {"step.discord_upload_file", "discord.v1.UploadFileConfig", "discord.v1.UploadFileOutput", func() sdk.StepInstance { return &uploadFileStep{} }}, + {"step.discord_create_thread", "discord.v1.CreateThreadConfig", "discord.v1.CreateThreadOutput", func() sdk.StepInstance { return &createThreadStep{} }}, + {"step.discord_voice_join", "discord.v1.VoiceJoinConfig", "discord.v1.VoiceJoinOutput", func() sdk.StepInstance { return &voiceJoinStep{} }}, + {"step.discord_voice_leave", "discord.v1.VoiceLeaveConfig", "discord.v1.VoiceLeaveOutput", func() sdk.StepInstance { return &voiceLeaveStep{} }}, + {"step.discord_voice_play", "discord.v1.VoicePlayConfig", "discord.v1.VoicePlayOutput", func() sdk.StepInstance { return &voicePlayStep{} }}, +} + +var discordTriggerRegistrations = []triggerRegistration{ + { + typeName: "trigger.discord", + config: "discord.v1.TriggerConfig", + output: "discord.v1.TriggerPayload", + create: func(config map[string]any, cb sdk.TriggerCallback) (sdk.TriggerInstance, error) { + return newDiscordTrigger(config, cb) + }, + }, +} + // New returns a new discordPlugin instance. func New() *discordPlugin { return &discordPlugin{} } @@ -29,76 +90,87 @@ func (p *discordPlugin) Manifest() sdk.PluginManifest { // ModuleTypes returns the module type names this plugin provides. func (p *discordPlugin) ModuleTypes() []string { - return []string{"discord.provider"} + types := make([]string, 0, len(discordModuleRegistrations)) + for _, registration := range discordModuleRegistrations { + types = append(types, registration.typeName) + } + return types } // StepTypes returns the step type names this plugin provides. func (p *discordPlugin) StepTypes() []string { - return []string{ - "step.discord_send_message", - "step.discord_send_embed", - "step.discord_edit_message", - "step.discord_delete_message", - "step.discord_add_reaction", - "step.discord_upload_file", - "step.discord_create_thread", - "step.discord_voice_join", - "step.discord_voice_leave", - "step.discord_voice_play", + types := make([]string, 0, len(discordStepRegistrations)) + for _, registration := range discordStepRegistrations { + types = append(types, registration.typeName) } + return types } // TriggerTypes returns the trigger type names this plugin provides. func (p *discordPlugin) TriggerTypes() []string { - return []string{"trigger.discord"} + types := make([]string, 0, len(discordTriggerRegistrations)) + for _, registration := range discordTriggerRegistrations { + types = append(types, registration.typeName) + } + return types +} + +func (p *discordPlugin) contractDescriptors() []contractDescriptor { + descriptors := make([]contractDescriptor, 0, len(discordModuleRegistrations)+len(discordStepRegistrations)+len(discordTriggerRegistrations)) + for _, registration := range discordModuleRegistrations { + descriptors = append(descriptors, contractDescriptor{ + Kind: "module", + Type: registration.typeName, + Config: registration.config, + }) + } + for _, registration := range discordStepRegistrations { + descriptors = append(descriptors, contractDescriptor{ + Kind: "step", + Type: registration.typeName, + Config: registration.config, + Output: registration.output, + }) + } + for _, registration := range discordTriggerRegistrations { + descriptors = append(descriptors, contractDescriptor{ + Kind: "trigger", + Type: registration.typeName, + Config: registration.config, + Output: registration.output, + }) + } + return descriptors } // CreateModule creates a module instance of the given type. func (p *discordPlugin) CreateModule(typeName, name string, config map[string]any) (sdk.ModuleInstance, error) { - switch typeName { - case "discord.provider": - return newDiscordProvider(name, config) - default: - return nil, fmt.Errorf("discord plugin: unknown module type %q", typeName) + for _, registration := range discordModuleRegistrations { + if registration.typeName == typeName { + return registration.create(name, config) + } } + return nil, fmt.Errorf("discord plugin: unknown module type %q", typeName) } // CreateStep creates a step instance of the given type. func (p *discordPlugin) CreateStep(typeName, name string, config map[string]any) (sdk.StepInstance, error) { // Steps need access to a provider session; they'll resolve it at Execute time // via a shared registry keyed by module name. - switch typeName { - case "step.discord_send_message": - return &sendMessageStep{}, nil - case "step.discord_send_embed": - return &sendEmbedStep{}, nil - case "step.discord_edit_message": - return &editMessageStep{}, nil - case "step.discord_delete_message": - return &deleteMessageStep{}, nil - case "step.discord_add_reaction": - return &addReactionStep{}, nil - case "step.discord_upload_file": - return &uploadFileStep{}, nil - case "step.discord_create_thread": - return &createThreadStep{}, nil - case "step.discord_voice_join": - return &voiceJoinStep{}, nil - case "step.discord_voice_leave": - return &voiceLeaveStep{}, nil - case "step.discord_voice_play": - return &voicePlayStep{}, nil - default: - return nil, fmt.Errorf("discord plugin: unknown step type %q", typeName) + for _, registration := range discordStepRegistrations { + if registration.typeName == typeName { + return registration.create(), nil + } } + return nil, fmt.Errorf("discord plugin: unknown step type %q", typeName) } // CreateTrigger creates a trigger instance of the given type. func (p *discordPlugin) CreateTrigger(typeName string, config map[string]any, cb sdk.TriggerCallback) (sdk.TriggerInstance, error) { - switch typeName { - case "trigger.discord": - return newDiscordTrigger(config, cb) - default: - return nil, fmt.Errorf("discord plugin: unknown trigger type %q", typeName) + for _, registration := range discordTriggerRegistrations { + if registration.typeName == typeName { + return registration.create(config, cb) + } } + return nil, fmt.Errorf("discord plugin: unknown trigger type %q", typeName) } diff --git a/internal/plugin_test.go b/internal/plugin_test.go index cd5e49e..2180575 100644 --- a/internal/plugin_test.go +++ b/internal/plugin_test.go @@ -151,19 +151,12 @@ func TestPluginContractsMatchRuntimeTypes(t *testing.T) { wantDescriptors := map[string]struct { config string output string - }{ - "module:discord.provider": {"discord.v1.ProviderConfig", ""}, - "step:step.discord_send_message": {"discord.v1.SendMessageConfig", "discord.v1.SendMessageOutput"}, - "step:step.discord_send_embed": {"discord.v1.SendEmbedConfig", "discord.v1.SendEmbedOutput"}, - "step:step.discord_edit_message": {"discord.v1.EditMessageConfig", "discord.v1.EditMessageOutput"}, - "step:step.discord_delete_message": {"discord.v1.DeleteMessageConfig", "discord.v1.DeleteMessageOutput"}, - "step:step.discord_add_reaction": {"discord.v1.AddReactionConfig", "discord.v1.AddReactionOutput"}, - "step:step.discord_upload_file": {"discord.v1.UploadFileConfig", "discord.v1.UploadFileOutput"}, - "step:step.discord_create_thread": {"discord.v1.CreateThreadConfig", "discord.v1.CreateThreadOutput"}, - "step:step.discord_voice_join": {"discord.v1.VoiceJoinConfig", "discord.v1.VoiceJoinOutput"}, - "step:step.discord_voice_leave": {"discord.v1.VoiceLeaveConfig", "discord.v1.VoiceLeaveOutput"}, - "step:step.discord_voice_play": {"discord.v1.VoicePlayConfig", "discord.v1.VoicePlayOutput"}, - "trigger:trigger.discord": {"discord.v1.TriggerConfig", "discord.v1.TriggerPayload"}, + }{} + for _, descriptor := range p.contractDescriptors() { + wantDescriptors[descriptor.Kind+":"+descriptor.Type] = struct { + config string + output string + }{descriptor.Config, descriptor.Output} } got := map[string]bool{} @@ -232,6 +225,7 @@ func TestGoReleaserValidatesRewrittenPluginManifest(t *testing.T) { releaseVersion := "9.8.7" tmp := t.TempDir() copyFile(t, filepath.Join(repoRoot(t), "plugin.json"), filepath.Join(tmp, "plugin.json")) + originalManifest := loadPluginManifestFrom(t, filepath.Join(tmp, "plugin.json")) binDir := t.TempDir() validationLog := filepath.Join(tmp, "validation.log") writeExecutable(t, filepath.Join(binDir, "go"), `#!/bin/sh @@ -290,7 +284,11 @@ printf 'validated %s\n' "$file" >> "$VALIDATION_LOG" } } - manifest := loadPluginManifestFrom(t, filepath.Join(tmp, "plugin.json")) + sourceManifest := loadPluginManifestFrom(t, filepath.Join(tmp, "plugin.json")) + if sourceManifest.Version != originalManifest.Version { + t.Fatalf("source manifest version = %q after hooks, want original %q", sourceManifest.Version, originalManifest.Version) + } + manifest := loadPluginManifestFrom(t, filepath.Join(tmp, "dist", "plugin.json")) if manifest.Version != releaseVersion { t.Fatalf("manifest version = %q, want %q", manifest.Version, releaseVersion) } @@ -300,9 +298,12 @@ printf 'validated %s\n' "$file" >> "$VALIDATION_LOG" t.Fatalf("download URL %q does not use release version %s", download.URL, releaseVersion) } } - if data, err := os.ReadFile(validationLog); err != nil || !strings.Contains(string(data), "validated plugin.json") { + if data, err := os.ReadFile(validationLog); err != nil || !strings.Contains(string(data), "validated dist/plugin.json") { t.Fatalf("strict validation was not invoked; log=%q err=%v", data, err) } + if _, err := os.Stat(filepath.Join(tmp, "plugin.json.orig")); !os.IsNotExist(err) { + t.Fatalf("plugin.json.orig should not be created, stat err=%v", err) + } } func TestCreateTriggerMissingToken(t *testing.T) { diff --git a/plugin.json b/plugin.json index 2fb2286..940e2c7 100644 --- a/plugin.json +++ b/plugin.json @@ -1,6 +1,6 @@ { "name": "discord", - "version": "0.1.0", + "version": "0.1.2", "author": "GoCodeAlone", "description": "Discord messaging, bots, and voice", "type": "external", @@ -115,22 +115,22 @@ { "os": "linux", "arch": "amd64", - "url": "https://github.com/GoCodeAlone/workflow-plugin-discord/releases/download/v0.1.0/workflow-plugin-discord-linux-amd64.tar.gz" + "url": "https://github.com/GoCodeAlone/workflow-plugin-discord/releases/download/v0.1.2/workflow-plugin-discord-linux-amd64.tar.gz" }, { "os": "linux", "arch": "arm64", - "url": "https://github.com/GoCodeAlone/workflow-plugin-discord/releases/download/v0.1.0/workflow-plugin-discord-linux-arm64.tar.gz" + "url": "https://github.com/GoCodeAlone/workflow-plugin-discord/releases/download/v0.1.2/workflow-plugin-discord-linux-arm64.tar.gz" }, { "os": "darwin", "arch": "amd64", - "url": "https://github.com/GoCodeAlone/workflow-plugin-discord/releases/download/v0.1.0/workflow-plugin-discord-darwin-amd64.tar.gz" + "url": "https://github.com/GoCodeAlone/workflow-plugin-discord/releases/download/v0.1.2/workflow-plugin-discord-darwin-amd64.tar.gz" }, { "os": "darwin", "arch": "arm64", - "url": "https://github.com/GoCodeAlone/workflow-plugin-discord/releases/download/v0.1.0/workflow-plugin-discord-darwin-arm64.tar.gz" + "url": "https://github.com/GoCodeAlone/workflow-plugin-discord/releases/download/v0.1.2/workflow-plugin-discord-darwin-arm64.tar.gz" } ] } From 415d775355b684ea3684870bb8ca4f585558f173 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sat, 2 May 2026 19:44:38 -0400 Subject: [PATCH 9/9] fix: keep source manifest on published release --- plugin.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/plugin.json b/plugin.json index 940e2c7..2fb2286 100644 --- a/plugin.json +++ b/plugin.json @@ -1,6 +1,6 @@ { "name": "discord", - "version": "0.1.2", + "version": "0.1.0", "author": "GoCodeAlone", "description": "Discord messaging, bots, and voice", "type": "external", @@ -115,22 +115,22 @@ { "os": "linux", "arch": "amd64", - "url": "https://github.com/GoCodeAlone/workflow-plugin-discord/releases/download/v0.1.2/workflow-plugin-discord-linux-amd64.tar.gz" + "url": "https://github.com/GoCodeAlone/workflow-plugin-discord/releases/download/v0.1.0/workflow-plugin-discord-linux-amd64.tar.gz" }, { "os": "linux", "arch": "arm64", - "url": "https://github.com/GoCodeAlone/workflow-plugin-discord/releases/download/v0.1.2/workflow-plugin-discord-linux-arm64.tar.gz" + "url": "https://github.com/GoCodeAlone/workflow-plugin-discord/releases/download/v0.1.0/workflow-plugin-discord-linux-arm64.tar.gz" }, { "os": "darwin", "arch": "amd64", - "url": "https://github.com/GoCodeAlone/workflow-plugin-discord/releases/download/v0.1.2/workflow-plugin-discord-darwin-amd64.tar.gz" + "url": "https://github.com/GoCodeAlone/workflow-plugin-discord/releases/download/v0.1.0/workflow-plugin-discord-darwin-amd64.tar.gz" }, { "os": "darwin", "arch": "arm64", - "url": "https://github.com/GoCodeAlone/workflow-plugin-discord/releases/download/v0.1.2/workflow-plugin-discord-darwin-arm64.tar.gz" + "url": "https://github.com/GoCodeAlone/workflow-plugin-discord/releases/download/v0.1.0/workflow-plugin-discord-darwin-arm64.tar.gz" } ] }