From 8b550dcaea3cc62fbe020153683d10dbd6527387 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:41 +0000 Subject: [PATCH 1/7] Initial plan From 10d5c65a053db691aa4d8d6e2e1f8c24e89c3296 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 1 May 2026 07:27:55 +0000 Subject: [PATCH 2/7] Add strict gRPC proto contracts: stepSchemas in plugin.json + SchemaProvider Agent-Logs-Url: https://github.com/GoCodeAlone/workflow-plugin-github/sessions/014b6a09-f49a-4822-aac8-e926355e3156 Co-authored-by: intel352 <77607+intel352@users.noreply.github.com> --- .gitignore | 1 + go.mod | 10 +- internal/schemas.go | 86 ++++++++++ internal/schemas_test.go | 112 +++++++++++++ plugin.json | 346 ++++++++++++++++++++++++++++++++++++--- 5 files changed, 528 insertions(+), 27 deletions(-) create mode 100644 .gitignore create mode 100644 internal/schemas.go create mode 100644 internal/schemas_test.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e660fd9 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +bin/ diff --git a/go.mod b/go.mod index d819aa1..25a55c4 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,12 @@ module github.com/GoCodeAlone/workflow-plugin-github go 1.26.0 -require github.com/GoCodeAlone/workflow v0.3.56 +require ( + github.com/GoCodeAlone/workflow v0.3.56 + github.com/golang-jwt/jwt/v5 v5.3.1 + github.com/google/go-github/v69 v69.2.0 + golang.org/x/crypto v0.48.0 +) require ( cel.dev/expr v0.25.1 // indirect @@ -94,10 +99,8 @@ require ( github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/gobwas/glob v0.2.3 // indirect - github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/golobby/cast v1.3.3 // indirect github.com/google/btree v1.1.3 // indirect - github.com/google/go-github/v69 v69.2.0 // indirect github.com/google/go-querystring v1.1.0 // indirect github.com/google/s2a-go v0.1.9 // indirect github.com/google/uuid v1.6.0 // indirect @@ -201,7 +204,6 @@ require ( go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.1 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect - golang.org/x/crypto v0.48.0 // indirect golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect golang.org/x/mod v0.33.0 // indirect golang.org/x/net v0.51.0 // indirect diff --git a/internal/schemas.go b/internal/schemas.go new file mode 100644 index 0000000..714aefa --- /dev/null +++ b/internal/schemas.go @@ -0,0 +1,86 @@ +package internal + +import sdk "github.com/GoCodeAlone/workflow/plugin/external/sdk" + +// Ensure githubPlugin satisfies sdk.SchemaProvider at compile time. +var _ sdk.SchemaProvider = (*githubPlugin)(nil) + +// ModuleSchemas returns schema descriptors for all module types provided by +// this plugin. Implementing sdk.SchemaProvider allows the engine to surface +// module configuration fields and I/O contracts at startup and in the UI. +func (p *githubPlugin) ModuleSchemas() []sdk.ModuleSchemaData { + return []sdk.ModuleSchemaData{ + { + Type: "git.webhook", + Label: "GitHub Webhook", + Category: "github", + Description: "Receives GitHub webhook events via HTTP, verifies HMAC-SHA256 signatures, and publishes normalised GitEvent messages to a configurable topic.", + ConfigFields: []sdk.ConfigField{ + { + Name: "provider", + Type: "string", + Description: "Webhook provider identifier", + DefaultValue: "github", + Required: false, + }, + { + Name: "secret", + Type: "string", + Description: "Webhook secret used to verify the X-Hub-Signature-256 header. Leave empty to skip signature verification.", + Required: false, + }, + { + Name: "events", + Type: "array", + Description: "Event types to accept (e.g. push, pull_request). An empty list accepts all event types.", + Required: false, + }, + { + Name: "topic", + Type: "string", + Description: "Message-bus topic to which normalised GitEvent payloads are published.", + DefaultValue: "git.events", + Required: false, + }, + }, + Outputs: []sdk.ServiceIO{ + {Name: "provider", Type: "string", Description: "Webhook provider (always 'github')"}, + {Name: "event_type", Type: "string", Description: "GitHub event type (e.g. push, pull_request)"}, + {Name: "repository", Type: "string", Description: "Repository full name (owner/repo)"}, + {Name: "branch", Type: "string", Description: "Branch or ref name"}, + {Name: "commit", Type: "string", Description: "Commit SHA"}, + {Name: "author", Type: "string", Description: "Event author username"}, + {Name: "message", Type: "string", Description: "Commit message or PR title"}, + {Name: "url", Type: "string", Description: "URL to the commit or PR"}, + {Name: "raw_payload", Type: "string", Description: "Raw JSON webhook payload"}, + {Name: "timestamp", Type: "string", Description: "Event timestamp in RFC3339 format"}, + }, + }, + { + Type: "github.app", + Label: "GitHub App", + Category: "github", + Description: "Authenticates as a GitHub App installation, generating short-lived installation access tokens from an App private key. Tokens are cached and refreshed automatically.", + ConfigFields: []sdk.ConfigField{ + { + Name: "app_id", + Type: "number", + Description: "GitHub App ID", + Required: true, + }, + { + Name: "installation_id", + Type: "number", + Description: "GitHub App installation ID", + Required: true, + }, + { + Name: "private_key", + Type: "string", + Description: "PEM-encoded RSA private key for the GitHub App (supports env var references e.g. ${GITHUB_APP_PRIVATE_KEY})", + Required: true, + }, + }, + }, + } +} diff --git a/internal/schemas_test.go b/internal/schemas_test.go new file mode 100644 index 0000000..103ef00 --- /dev/null +++ b/internal/schemas_test.go @@ -0,0 +1,112 @@ +package internal + +import ( + "encoding/json" + "os" + "testing" +) + +// TestModuleSchemas verifies that the plugin's SchemaProvider returns schema +// descriptors for both advertised module types. +func TestModuleSchemas(t *testing.T) { + p := &githubPlugin{} + schemas := p.ModuleSchemas() + + if len(schemas) != 2 { + t.Fatalf("expected 2 module schemas, got %d", len(schemas)) + } + + byType := make(map[string]int, len(schemas)) + for i, s := range schemas { + byType[s.Type] = i + } + + for _, wantType := range []string{"git.webhook", "github.app"} { + if _, ok := byType[wantType]; !ok { + t.Errorf("missing module schema for type %q", wantType) + } + } + + // git.webhook should have at least the four documented config fields. + webhookIdx, ok := byType["git.webhook"] + if !ok { + t.Fatalf("git.webhook schema not found") + } + webhook := schemas[webhookIdx] + if webhook.Label == "" { + t.Error("git.webhook schema: Label must not be empty") + } + if webhook.Description == "" { + t.Error("git.webhook schema: Description must not be empty") + } + if len(webhook.ConfigFields) < 4 { + t.Errorf("git.webhook schema: expected at least 4 config fields, got %d", len(webhook.ConfigFields)) + } + if len(webhook.Outputs) == 0 { + t.Error("git.webhook schema: expected at least one output") + } + + // github.app should declare the three required config fields. + appIdx, ok := byType["github.app"] + if !ok { + t.Fatalf("github.app schema not found") + } + app := schemas[appIdx] + if app.Label == "" { + t.Error("github.app schema: Label must not be empty") + } + if app.Description == "" { + t.Error("github.app schema: Description must not be empty") + } + requiredFields := map[string]bool{} + for _, f := range app.ConfigFields { + if f.Required { + requiredFields[f.Name] = true + } + } + for _, want := range []string{"app_id", "installation_id", "private_key"} { + if !requiredFields[want] { + t.Errorf("github.app schema: field %q should be marked required", want) + } + } +} + +// TestPluginStepSchemasJSON verifies that plugin.json can be parsed and that +// it declares a stepSchemas entry for every step type the plugin advertises. +func TestPluginStepSchemasJSON(t *testing.T) { + // Locate plugin.json relative to the repository root (one level up from internal/). + data, err := os.ReadFile("../plugin.json") + if err != nil { + t.Skipf("plugin.json not found (skipping in isolated test environments): %v", err) + } + + var manifest struct { + StepTypes []string `json:"stepTypes"` + StepSchemas []struct { + Type string `json:"type"` + } `json:"stepSchemas"` + } + if err := json.Unmarshal(data, &manifest); err != nil { + t.Fatalf("parse plugin.json: %v", err) + } + + if len(manifest.StepTypes) == 0 { + t.Fatal("plugin.json: stepTypes must not be empty") + } + + schemaSet := make(map[string]bool, len(manifest.StepSchemas)) + for _, s := range manifest.StepSchemas { + schemaSet[s.Type] = true + } + + for _, stepType := range manifest.StepTypes { + if !schemaSet[stepType] { + t.Errorf("plugin.json: stepType %q has no corresponding stepSchema entry", stepType) + } + } + + if len(manifest.StepSchemas) != len(manifest.StepTypes) { + t.Errorf("plugin.json: stepSchemas count (%d) does not match stepTypes count (%d)", + len(manifest.StepSchemas), len(manifest.StepTypes)) + } +} diff --git a/plugin.json b/plugin.json index 2bb5598..3505736 100644 --- a/plugin.json +++ b/plugin.json @@ -10,27 +10,327 @@ "keywords": ["github", "webhook", "git", "actions", "ci", "cd", "integration", "pull-request", "release"], "homepage": "https://github.com/GoCodeAlone/workflow-plugin-github", "repository": "https://github.com/GoCodeAlone/workflow-plugin-github", - "capabilities": { - "configProvider": false, - "moduleTypes": ["git.webhook", "github.app"], - "stepTypes": [ - "step.gh_action_trigger", - "step.gh_action_status", - "step.gh_create_check", - "step.gh_pr_create", - "step.gh_pr_merge", - "step.gh_pr_comment", - "step.gh_pr_review", - "step.gh_issue_create", - "step.gh_issue_close", - "step.gh_issue_label", - "step.gh_release_create", - "step.gh_release_upload", - "step.gh_repo_dispatch", - "step.gh_deployment_create", - "step.gh_secret_set", - "step.gh_graphql" - ], - "triggerTypes": [] - } + "moduleTypes": ["git.webhook", "github.app"], + "stepTypes": [ + "step.gh_action_trigger", + "step.gh_action_status", + "step.gh_create_check", + "step.gh_pr_create", + "step.gh_pr_merge", + "step.gh_pr_comment", + "step.gh_pr_review", + "step.gh_issue_create", + "step.gh_issue_close", + "step.gh_issue_label", + "step.gh_release_create", + "step.gh_release_upload", + "step.gh_repo_dispatch", + "step.gh_deployment_create", + "step.gh_secret_set", + "step.gh_graphql" + ], + "triggerTypes": [], + "stepSchemas": [ + { + "type": "step.gh_action_trigger", + "plugin": "workflow-plugin-github", + "description": "Triggers a GitHub Actions workflow run via the workflow_dispatch API.", + "configFields": [ + {"key": "owner", "type": "string", "description": "GitHub repository owner (user or organisation)", "required": true}, + {"key": "repo", "type": "string", "description": "GitHub repository name", "required": true}, + {"key": "workflow", "type": "string", "description": "Workflow filename or ID (e.g. ci.yml)", "required": true}, + {"key": "ref", "type": "string", "description": "Branch or tag reference to run the workflow on", "defaultValue": "main"}, + {"key": "inputs", "type": "map", "description": "Optional workflow_dispatch input key/value pairs"}, + {"key": "token", "type": "string", "description": "GitHub personal access token with workflow scope", "sensitive": true} + ], + "outputs": [ + {"key": "triggered", "type": "boolean", "description": "Whether the workflow run was successfully triggered"}, + {"key": "owner", "type": "string", "description": "Repository owner"}, + {"key": "repo", "type": "string", "description": "Repository name"}, + {"key": "workflow", "type": "string", "description": "Workflow filename or ID"}, + {"key": "ref", "type": "string", "description": "Branch or tag reference"} + ] + }, + { + "type": "step.gh_action_status", + "plugin": "workflow-plugin-github", + "description": "Checks (and optionally polls) the status of a GitHub Actions workflow run.", + "configFields": [ + {"key": "owner", "type": "string", "description": "GitHub repository owner", "required": true}, + {"key": "repo", "type": "string", "description": "GitHub repository name", "required": true}, + {"key": "run_id", "type": "string", "description": "Workflow run ID (integer or template expression)", "required": true}, + {"key": "token", "type": "string", "description": "GitHub personal access token", "sensitive": true}, + {"key": "wait", "type": "boolean", "description": "Poll until the run reaches a terminal state", "defaultValue": false}, + {"key": "poll_interval", "type": "duration", "description": "Interval between status polls when wait=true", "defaultValue": "10s"}, + {"key": "timeout", "type": "duration", "description": "Maximum time to wait when wait=true", "defaultValue": "30m"} + ], + "outputs": [ + {"key": "run_id", "type": "number", "description": "Workflow run ID"}, + {"key": "status", "type": "string", "description": "Run status (e.g. queued, in_progress, completed)"}, + {"key": "conclusion", "type": "string", "description": "Run conclusion (e.g. success, failure, cancelled)"}, + {"key": "url", "type": "string", "description": "URL to the workflow run"} + ] + }, + { + "type": "step.gh_create_check", + "plugin": "workflow-plugin-github", + "description": "Creates or updates a GitHub Check Run (status check) on a specific commit.", + "configFields": [ + {"key": "owner", "type": "string", "description": "GitHub repository owner", "required": true}, + {"key": "repo", "type": "string", "description": "GitHub repository name", "required": true}, + {"key": "sha", "type": "string", "description": "Commit SHA to associate the check with", "required": true}, + {"key": "name", "type": "string", "description": "Name of the check run", "required": true}, + {"key": "status", "type": "select", "description": "Check run status", "options": ["queued", "in_progress", "completed"], "defaultValue": "queued"}, + {"key": "conclusion", "type": "select", "description": "Check run conclusion (required when status=completed)", "options": ["success", "failure", "neutral", "cancelled", "skipped", "timed_out", "action_required"]}, + {"key": "title", "type": "string", "description": "Check output title"}, + {"key": "summary", "type": "string", "description": "Check output summary"}, + {"key": "token", "type": "string", "description": "GitHub personal access token", "sensitive": true} + ], + "outputs": [ + {"key": "check_run_id", "type": "number", "description": "Check run ID"}, + {"key": "status", "type": "string", "description": "Check run status"}, + {"key": "url", "type": "string", "description": "URL to the check run"} + ] + }, + { + "type": "step.gh_pr_create", + "plugin": "workflow-plugin-github", + "description": "Creates a pull request in a GitHub repository.", + "configFields": [ + {"key": "owner", "type": "string", "description": "GitHub repository owner", "required": true}, + {"key": "repo", "type": "string", "description": "GitHub repository name", "required": true}, + {"key": "head", "type": "string", "description": "Source branch name", "required": true}, + {"key": "base", "type": "string", "description": "Target branch name", "defaultValue": "main"}, + {"key": "title", "type": "string", "description": "Pull request title"}, + {"key": "body", "type": "string", "description": "Pull request description"}, + {"key": "draft", "type": "boolean", "description": "Whether to create as a draft PR", "defaultValue": false}, + {"key": "token", "type": "string", "description": "GitHub personal access token", "sensitive": true} + ], + "outputs": [ + {"key": "number", "type": "number", "description": "Pull request number"}, + {"key": "url", "type": "string", "description": "Pull request URL"}, + {"key": "id", "type": "number", "description": "Pull request ID"}, + {"key": "state", "type": "string", "description": "Pull request state (open/closed)"} + ] + }, + { + "type": "step.gh_pr_merge", + "plugin": "workflow-plugin-github", + "description": "Merges a pull request in a GitHub repository.", + "configFields": [ + {"key": "owner", "type": "string", "description": "GitHub repository owner", "required": true}, + {"key": "repo", "type": "string", "description": "GitHub repository name", "required": true}, + {"key": "pr_number", "type": "number", "description": "Pull request number to merge", "required": true}, + {"key": "commit_title", "type": "string", "description": "Merge commit title"}, + {"key": "method", "type": "select", "description": "Merge method", "options": ["merge", "squash", "rebase"], "defaultValue": "merge"}, + {"key": "token", "type": "string", "description": "GitHub personal access token", "sensitive": true} + ], + "outputs": [ + {"key": "merged", "type": "boolean", "description": "Whether the merge succeeded"}, + {"key": "message", "type": "string", "description": "Result message from GitHub"}, + {"key": "sha", "type": "string", "description": "Merge commit SHA"} + ] + }, + { + "type": "step.gh_pr_comment", + "plugin": "workflow-plugin-github", + "description": "Adds a comment to a GitHub pull request.", + "configFields": [ + {"key": "owner", "type": "string", "description": "GitHub repository owner", "required": true}, + {"key": "repo", "type": "string", "description": "GitHub repository name", "required": true}, + {"key": "pr_number", "type": "number", "description": "Pull request number", "required": true}, + {"key": "body", "type": "string", "description": "Comment text"}, + {"key": "token", "type": "string", "description": "GitHub personal access token", "sensitive": true} + ], + "outputs": [ + {"key": "comment_id", "type": "number", "description": "Comment ID"}, + {"key": "url", "type": "string", "description": "Comment URL"} + ] + }, + { + "type": "step.gh_pr_review", + "plugin": "workflow-plugin-github", + "description": "Submits a review on a GitHub pull request (approve, request changes, or comment).", + "configFields": [ + {"key": "owner", "type": "string", "description": "GitHub repository owner", "required": true}, + {"key": "repo", "type": "string", "description": "GitHub repository name", "required": true}, + {"key": "pr_number", "type": "number", "description": "Pull request number", "required": true}, + {"key": "event", "type": "select", "description": "Review event type", "options": ["APPROVE", "REQUEST_CHANGES", "COMMENT"], "defaultValue": "COMMENT"}, + {"key": "body", "type": "string", "description": "Review body text"}, + {"key": "token", "type": "string", "description": "GitHub personal access token", "sensitive": true} + ], + "outputs": [ + {"key": "review_id", "type": "number", "description": "Review ID"}, + {"key": "state", "type": "string", "description": "Review state"}, + {"key": "url", "type": "string", "description": "Review URL"} + ] + }, + { + "type": "step.gh_issue_create", + "plugin": "workflow-plugin-github", + "description": "Creates a GitHub issue.", + "configFields": [ + {"key": "owner", "type": "string", "description": "GitHub repository owner", "required": true}, + {"key": "repo", "type": "string", "description": "GitHub repository name", "required": true}, + {"key": "title", "type": "string", "description": "Issue title"}, + {"key": "body", "type": "string", "description": "Issue body"}, + {"key": "labels", "type": "array", "description": "Labels to attach to the issue"}, + {"key": "assignees", "type": "array", "description": "GitHub usernames to assign to the issue"}, + {"key": "token", "type": "string", "description": "GitHub personal access token", "sensitive": true} + ], + "outputs": [ + {"key": "number", "type": "number", "description": "Issue number"}, + {"key": "url", "type": "string", "description": "Issue URL"}, + {"key": "id", "type": "number", "description": "Issue ID"}, + {"key": "state", "type": "string", "description": "Issue state (open)"} + ] + }, + { + "type": "step.gh_issue_close", + "plugin": "workflow-plugin-github", + "description": "Closes a GitHub issue, optionally adding a comment before closing.", + "configFields": [ + {"key": "owner", "type": "string", "description": "GitHub repository owner", "required": true}, + {"key": "repo", "type": "string", "description": "GitHub repository name", "required": true}, + {"key": "issue_number", "type": "number", "description": "Issue number to close", "required": true}, + {"key": "comment", "type": "string", "description": "Optional closing comment"}, + {"key": "token", "type": "string", "description": "GitHub personal access token", "sensitive": true} + ], + "outputs": [ + {"key": "number", "type": "number", "description": "Issue number"}, + {"key": "state", "type": "string", "description": "Issue state (closed)"}, + {"key": "url", "type": "string", "description": "Issue URL"} + ] + }, + { + "type": "step.gh_issue_label", + "plugin": "workflow-plugin-github", + "description": "Adds or removes labels on a GitHub issue or pull request.", + "configFields": [ + {"key": "owner", "type": "string", "description": "GitHub repository owner", "required": true}, + {"key": "repo", "type": "string", "description": "GitHub repository name", "required": true}, + {"key": "issue_number", "type": "number", "description": "Issue or pull request number", "required": true}, + {"key": "add", "type": "array", "description": "Labels to add"}, + {"key": "remove", "type": "array", "description": "Labels to remove"}, + {"key": "token", "type": "string", "description": "GitHub personal access token", "sensitive": true} + ], + "outputs": [ + {"key": "added", "type": "array", "description": "Labels that were added"}, + {"key": "removed", "type": "array", "description": "Labels that were removed"} + ] + }, + { + "type": "step.gh_release_create", + "plugin": "workflow-plugin-github", + "description": "Creates a GitHub release.", + "configFields": [ + {"key": "owner", "type": "string", "description": "GitHub repository owner", "required": true}, + {"key": "repo", "type": "string", "description": "GitHub repository name", "required": true}, + {"key": "tag", "type": "string", "description": "Git tag name for the release", "required": true}, + {"key": "name", "type": "string", "description": "Release display name"}, + {"key": "body", "type": "string", "description": "Release notes / changelog"}, + {"key": "draft", "type": "boolean", "description": "Whether to create as a draft release", "defaultValue": false}, + {"key": "prerelease", "type": "boolean", "description": "Whether this is a pre-release", "defaultValue": false}, + {"key": "token", "type": "string", "description": "GitHub personal access token", "sensitive": true} + ], + "outputs": [ + {"key": "release_id", "type": "number", "description": "Release ID"}, + {"key": "url", "type": "string", "description": "Release URL"}, + {"key": "upload_url", "type": "string", "description": "Asset upload URL"}, + {"key": "tag", "type": "string", "description": "Tag name"}, + {"key": "draft", "type": "boolean", "description": "Whether the release is a draft"}, + {"key": "prerelease", "type": "boolean", "description": "Whether the release is a pre-release"} + ] + }, + { + "type": "step.gh_release_upload", + "plugin": "workflow-plugin-github", + "description": "Uploads a file asset to an existing GitHub release.", + "configFields": [ + {"key": "owner", "type": "string", "description": "GitHub repository owner", "required": true}, + {"key": "repo", "type": "string", "description": "GitHub repository name", "required": true}, + {"key": "release_id", "type": "number", "description": "Release ID (from step.gh_release_create output)", "required": true}, + {"key": "file", "type": "filepath", "description": "Local path to the file to upload", "required": true}, + {"key": "name", "type": "string", "description": "Display name for the release asset"}, + {"key": "token", "type": "string", "description": "GitHub personal access token", "sensitive": true} + ], + "outputs": [ + {"key": "asset_id", "type": "number", "description": "Asset ID"}, + {"key": "url", "type": "string", "description": "Asset download URL"}, + {"key": "name", "type": "string", "description": "Asset name"}, + {"key": "size", "type": "number", "description": "Asset file size in bytes"} + ] + }, + { + "type": "step.gh_repo_dispatch", + "plugin": "workflow-plugin-github", + "description": "Sends a repository_dispatch event to trigger external GitHub Actions workflows.", + "configFields": [ + {"key": "owner", "type": "string", "description": "GitHub repository owner", "required": true}, + {"key": "repo", "type": "string", "description": "GitHub repository name", "required": true}, + {"key": "event_type", "type": "string", "description": "Custom event type name", "required": true}, + {"key": "payload", "type": "map", "description": "Client payload data to include with the event"}, + {"key": "token", "type": "string", "description": "GitHub personal access token", "sensitive": true} + ], + "outputs": [ + {"key": "dispatched", "type": "boolean", "description": "Whether the event was dispatched"}, + {"key": "event_type", "type": "string", "description": "Event type that was dispatched"}, + {"key": "owner", "type": "string", "description": "Repository owner"}, + {"key": "repo", "type": "string", "description": "Repository name"} + ] + }, + { + "type": "step.gh_deployment_create", + "plugin": "workflow-plugin-github", + "description": "Creates a GitHub deployment.", + "configFields": [ + {"key": "owner", "type": "string", "description": "GitHub repository owner", "required": true}, + {"key": "repo", "type": "string", "description": "GitHub repository name", "required": true}, + {"key": "ref", "type": "string", "description": "Branch, tag, or SHA to deploy", "defaultValue": "main"}, + {"key": "environment", "type": "string", "description": "Target deployment environment", "defaultValue": "production"}, + {"key": "description", "type": "string", "description": "Deployment description"}, + {"key": "auto_merge", "type": "boolean", "description": "Auto-merge the default branch before deploying", "defaultValue": false}, + {"key": "token", "type": "string", "description": "GitHub personal access token", "sensitive": true} + ], + "outputs": [ + {"key": "deployment_id", "type": "number", "description": "Deployment ID"}, + {"key": "environment", "type": "string", "description": "Target environment"}, + {"key": "ref", "type": "string", "description": "Ref that was deployed"}, + {"key": "sha", "type": "string", "description": "Commit SHA"}, + {"key": "url", "type": "string", "description": "Deployment URL"} + ] + }, + { + "type": "step.gh_secret_set", + "plugin": "workflow-plugin-github", + "description": "Creates or updates a repository secret, encrypting the value with the repo's public key.", + "configFields": [ + {"key": "owner", "type": "string", "description": "GitHub repository owner or organisation name", "required": true}, + {"key": "repo", "type": "string", "description": "Repository name (omit for org-level secrets)"}, + {"key": "name", "type": "string", "description": "Secret name", "required": true}, + {"key": "value", "type": "string", "description": "Secret value (supports env var references)", "sensitive": true}, + {"key": "token", "type": "string", "description": "GitHub personal access token with repo secrets permission", "sensitive": true} + ], + "outputs": [ + {"key": "name", "type": "string", "description": "Secret name"}, + {"key": "owner", "type": "string", "description": "Repository owner"}, + {"key": "repo", "type": "string", "description": "Repository name"}, + {"key": "set", "type": "boolean", "description": "Whether the secret was set successfully"} + ] + }, + { + "type": "step.gh_graphql", + "plugin": "workflow-plugin-github", + "description": "Executes an arbitrary GraphQL query against the GitHub API.", + "configFields": [ + {"key": "query", "type": "string", "description": "GraphQL query string", "required": true}, + {"key": "variables", "type": "map", "description": "GraphQL query variables"}, + {"key": "token", "type": "string", "description": "GitHub personal access token", "sensitive": true} + ], + "outputs": [ + {"key": "data", "type": "map", "description": "GraphQL response data object"}, + {"key": "status", "type": "number", "description": "HTTP status code from the GraphQL endpoint"} + ] + } + ] } From c6e8d227571599db4ab0f5607a6ee289ab73012a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 1 May 2026 07:45:40 +0000 Subject: [PATCH 3/7] Add strict-contracts CI job, strengthen contract tests, add engine manifest validation Agent-Logs-Url: https://github.com/GoCodeAlone/workflow-plugin-github/sessions/f07d93ad-41a2-4378-bba2-410c8ca11dc9 Co-authored-by: intel352 <77607+intel352@users.noreply.github.com> --- .github/workflows/ci.yml | 32 ++++++++++++++++++++++++++++++++ internal/schemas_test.go | 36 +++++++++++++++++++++++++++++++++++- 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c5c33f7..a22e1db 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,3 +14,35 @@ jobs: go-version-file: go.mod - run: go build ./... - run: go test ./... -v -race -count=1 + env: + GOPRIVATE: github.com/GoCodeAlone/* + GONOSUMCHECK: github.com/GoCodeAlone/* + + strict-contracts: + name: Validate strict plugin contracts + runs-on: [self-hosted, Linux, X64] + permissions: + contents: read + steps: + - uses: actions/checkout@v6 + - name: Verify plugin.json exists + run: | + test -f plugin.json || { echo "ERROR: plugin.json is missing — every release must include a strict contract manifest"; exit 1; } + - uses: actions/setup-go@v6 + with: + go-version-file: go.mod + - name: Run strict contract tests + run: | + go test ./internal/... -run "TestPluginStepSchemasJSON|TestPluginManifestEngineValidation|TestModuleSchemas" -v -count=1 + env: + GOPRIVATE: github.com/GoCodeAlone/* + GONOSUMCHECK: github.com/GoCodeAlone/* + - name: Validate plugin.json with wfctl + run: | + # wfctl validates registry-format manifests; strict contract schema coverage is enforced + # by the Go tests above. This step runs informational validation and logs the result. + go run github.com/GoCodeAlone/workflow/cmd/wfctl@v0.3.56 plugin validate --file plugin.json 2>&1; wfctl_exit=$? + echo "wfctl validation exit code: ${wfctl_exit}" + env: + GOPRIVATE: github.com/GoCodeAlone/* + GONOSUMCHECK: github.com/GoCodeAlone/* diff --git a/internal/schemas_test.go b/internal/schemas_test.go index 103ef00..f72a3e4 100644 --- a/internal/schemas_test.go +++ b/internal/schemas_test.go @@ -4,6 +4,8 @@ import ( "encoding/json" "os" "testing" + + "github.com/GoCodeAlone/workflow/plugin" ) // TestModuleSchemas verifies that the plugin's SchemaProvider returns schema @@ -77,7 +79,7 @@ func TestPluginStepSchemasJSON(t *testing.T) { // Locate plugin.json relative to the repository root (one level up from internal/). data, err := os.ReadFile("../plugin.json") if err != nil { - t.Skipf("plugin.json not found (skipping in isolated test environments): %v", err) + t.Fatalf("plugin.json not found — every build must ship a contract manifest: %v", err) } var manifest struct { @@ -110,3 +112,35 @@ func TestPluginStepSchemasJSON(t *testing.T) { len(manifest.StepSchemas), len(manifest.StepTypes)) } } + +// TestPluginManifestEngineValidation verifies that plugin.json is parseable as a +// workflow engine PluginManifest and that Validate() passes required-field checks. +func TestPluginManifestEngineValidation(t *testing.T) { + m, err := plugin.LoadManifest("../plugin.json") + if err != nil { + t.Fatalf("plugin.LoadManifest: %v", err) + } + if err := m.Validate(); err != nil { + t.Fatalf("plugin.json fails engine manifest validation: %v", err) + } + // Strict contract requirements: must declare at least one module or step type. + if len(m.ModuleTypes) == 0 && len(m.StepTypes) == 0 { + t.Error("plugin.json: must advertise at least one moduleType or stepType for strict contracts") + } + // StepSchemas must be present when step types are declared. + if len(m.StepTypes) > 0 && len(m.StepSchemas) == 0 { + t.Error("plugin.json: stepSchemas is required when stepTypes are declared (missing_step_contract_descriptor)") + } + // Every step type must have a schema entry. + schemaSet := make(map[string]bool, len(m.StepSchemas)) + for _, s := range m.StepSchemas { + if s != nil { + schemaSet[s.Type] = true + } + } + for _, st := range m.StepTypes { + if !schemaSet[st] { + t.Errorf("plugin.json: stepType %q has no stepSchema (missing_step_contract_descriptor)", st) + } + } +} From d3aba964b3f24c3740eeff484f2ce4f4093c2ef0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 2 May 2026 14:00:38 +0000 Subject: [PATCH 4/7] Address all 9 review thread comments: fix pr_merge method, release_upload templates, schema accuracy, token required, type corrections, runtime sync tests Agent-Logs-Url: https://github.com/GoCodeAlone/workflow-plugin-github/sessions/e1f18cab-168a-453e-a97d-dd290c4cfcae Co-authored-by: intel352 <77607+intel352@users.noreply.github.com> --- internal/schemas.go | 9 +-- internal/schemas_test.go | 113 +++++++++++++++++++++++++++++--- internal/step_pr_merge.go | 5 +- internal/step_release_upload.go | 45 ++++++++++--- plugin.json | 38 +++++------ 5 files changed, 164 insertions(+), 46 deletions(-) diff --git a/internal/schemas.go b/internal/schemas.go index 714aefa..ea740ca 100644 --- a/internal/schemas.go +++ b/internal/schemas.go @@ -16,13 +16,6 @@ func (p *githubPlugin) ModuleSchemas() []sdk.ModuleSchemaData { Category: "github", Description: "Receives GitHub webhook events via HTTP, verifies HMAC-SHA256 signatures, and publishes normalised GitEvent messages to a configurable topic.", ConfigFields: []sdk.ConfigField{ - { - Name: "provider", - Type: "string", - Description: "Webhook provider identifier", - DefaultValue: "github", - Required: false, - }, { Name: "secret", Type: "string", @@ -52,7 +45,7 @@ func (p *githubPlugin) ModuleSchemas() []sdk.ModuleSchemaData { {Name: "author", Type: "string", Description: "Event author username"}, {Name: "message", Type: "string", Description: "Commit message or PR title"}, {Name: "url", Type: "string", Description: "URL to the commit or PR"}, - {Name: "raw_payload", Type: "string", Description: "Raw JSON webhook payload"}, + {Name: "raw_payload", Type: "object", Description: "Raw JSON webhook payload"}, {Name: "timestamp", Type: "string", Description: "Event timestamp in RFC3339 format"}, }, }, diff --git a/internal/schemas_test.go b/internal/schemas_test.go index f72a3e4..6cf26f0 100644 --- a/internal/schemas_test.go +++ b/internal/schemas_test.go @@ -3,34 +3,54 @@ package internal import ( "encoding/json" "os" + "sort" "testing" "github.com/GoCodeAlone/workflow/plugin" ) // TestModuleSchemas verifies that the plugin's SchemaProvider returns schema -// descriptors for both advertised module types. +// descriptors for both advertised module types and stays in sync with +// ModuleTypes() and plugin.json moduleTypes. func TestModuleSchemas(t *testing.T) { p := &githubPlugin{} schemas := p.ModuleSchemas() + runtimeTypes := p.ModuleTypes() if len(schemas) != 2 { t.Fatalf("expected 2 module schemas, got %d", len(schemas)) } - byType := make(map[string]int, len(schemas)) + // Every schema type must appear in the runtime ModuleTypes() list. + runtimeSet := make(map[string]bool, len(runtimeTypes)) + for _, mt := range runtimeTypes { + runtimeSet[mt] = true + } + for _, s := range schemas { + if !runtimeSet[s.Type] { + t.Errorf("schema type %q is not in githubPlugin.ModuleTypes()", s.Type) + } + } + + // Every runtime module type must have a schema entry. + schemaSet := make(map[string]int, len(schemas)) for i, s := range schemas { - byType[s.Type] = i + schemaSet[s.Type] = i + } + for _, mt := range runtimeTypes { + if _, ok := schemaSet[mt]; !ok { + t.Errorf("ModuleTypes() returns %q but no corresponding ModuleSchema exists", mt) + } } for _, wantType := range []string{"git.webhook", "github.app"} { - if _, ok := byType[wantType]; !ok { + if _, ok := schemaSet[wantType]; !ok { t.Errorf("missing module schema for type %q", wantType) } } - // git.webhook should have at least the four documented config fields. - webhookIdx, ok := byType["git.webhook"] + // git.webhook should have the core documented config fields. + webhookIdx, ok := schemaSet["git.webhook"] if !ok { t.Fatalf("git.webhook schema not found") } @@ -41,15 +61,27 @@ func TestModuleSchemas(t *testing.T) { if webhook.Description == "" { t.Error("git.webhook schema: Description must not be empty") } - if len(webhook.ConfigFields) < 4 { - t.Errorf("git.webhook schema: expected at least 4 config fields, got %d", len(webhook.ConfigFields)) + webhookFieldNames := make(map[string]bool, len(webhook.ConfigFields)) + for _, f := range webhook.ConfigFields { + webhookFieldNames[f.Name] = true + } + for _, want := range []string{"secret", "events", "topic"} { + if !webhookFieldNames[want] { + t.Errorf("git.webhook schema: required config field %q is missing", want) + } } if len(webhook.Outputs) == 0 { t.Error("git.webhook schema: expected at least one output") } + // raw_payload must be declared as an object (json.RawMessage), not string. + for _, f := range webhook.Outputs { + if f.Name == "raw_payload" && f.Type != "object" { + t.Errorf("git.webhook schema: raw_payload output type should be %q, got %q", "object", f.Type) + } + } // github.app should declare the three required config fields. - appIdx, ok := byType["github.app"] + appIdx, ok := schemaSet["github.app"] if !ok { t.Fatalf("github.app schema not found") } @@ -71,10 +103,37 @@ func TestModuleSchemas(t *testing.T) { t.Errorf("github.app schema: field %q should be marked required", want) } } + + // Cross-check against plugin.json moduleTypes. + data, err := os.ReadFile("../plugin.json") + if err != nil { + t.Fatalf("plugin.json not found: %v", err) + } + var manifest struct { + ModuleTypes []string `json:"moduleTypes"` + } + if err := json.Unmarshal(data, &manifest); err != nil { + t.Fatalf("parse plugin.json: %v", err) + } + jsonModuleSet := make(map[string]bool, len(manifest.ModuleTypes)) + for _, mt := range manifest.ModuleTypes { + jsonModuleSet[mt] = true + } + for _, mt := range runtimeTypes { + if !jsonModuleSet[mt] { + t.Errorf("githubPlugin.ModuleTypes() returns %q but plugin.json moduleTypes does not include it", mt) + } + } + for _, mt := range manifest.ModuleTypes { + if !runtimeSet[mt] { + t.Errorf("plugin.json moduleTypes includes %q but githubPlugin.ModuleTypes() does not", mt) + } + } } // TestPluginStepSchemasJSON verifies that plugin.json can be parsed and that -// it declares a stepSchemas entry for every step type the plugin advertises. +// it declares a stepSchemas entry for every step type the plugin advertises, +// and that both are in sync with the runtime StepTypes() list. func TestPluginStepSchemasJSON(t *testing.T) { // Locate plugin.json relative to the repository root (one level up from internal/). data, err := os.ReadFile("../plugin.json") @@ -111,6 +170,40 @@ func TestPluginStepSchemasJSON(t *testing.T) { t.Errorf("plugin.json: stepSchemas count (%d) does not match stepTypes count (%d)", len(manifest.StepSchemas), len(manifest.StepTypes)) } + + // Cross-check JSON manifest against the runtime StepTypes() list. + p := &githubPlugin{} + runtimeTypes := p.StepTypes() + + runtimeSet := make(map[string]bool, len(runtimeTypes)) + for _, st := range runtimeTypes { + runtimeSet[st] = true + } + jsonTypeSet := make(map[string]bool, len(manifest.StepTypes)) + for _, st := range manifest.StepTypes { + jsonTypeSet[st] = true + } + + for _, rt := range runtimeTypes { + if !jsonTypeSet[rt] { + t.Errorf("githubPlugin.StepTypes() returns %q but plugin.json stepTypes does not include it", rt) + } + } + for _, jt := range manifest.StepTypes { + if !runtimeSet[jt] { + t.Errorf("plugin.json stepTypes includes %q but githubPlugin.StepTypes() does not return it", jt) + } + } + + // Verify both lists have the same length (no duplicates or gaps). + sortedRuntime := append([]string(nil), runtimeTypes...) + sortedJSON := append([]string(nil), manifest.StepTypes...) + sort.Strings(sortedRuntime) + sort.Strings(sortedJSON) + if len(sortedRuntime) != len(sortedJSON) { + t.Errorf("runtime StepTypes() count (%d) != plugin.json stepTypes count (%d)", + len(sortedRuntime), len(sortedJSON)) + } } // TestPluginManifestEngineValidation verifies that plugin.json is parseable as a diff --git a/internal/step_pr_merge.go b/internal/step_pr_merge.go index 4628aca..8e4ec63 100644 --- a/internal/step_pr_merge.go +++ b/internal/step_pr_merge.go @@ -5,6 +5,8 @@ import ( "fmt" "os" + "github.com/google/go-github/v69/github" + sdk "github.com/GoCodeAlone/workflow/plugin/external/sdk" ) @@ -81,8 +83,9 @@ func (s *prMergeStep) Execute( commitTitle := resolveField(s.config.CommitTitle, triggerData, stepOutputs, current) client := NewSDKClient(token) + method := resolveField(s.config.Method, triggerData, stepOutputs, current) result, _, err := client.GH.PullRequests.Merge(ctx, owner, repo, s.config.PRNumber, - commitTitle, nil) + commitTitle, &github.PullRequestOptions{MergeMethod: method}) if err != nil { return errorResult(fmt.Sprintf("merge PR: %v", err)), nil } diff --git a/internal/step_release_upload.go b/internal/step_release_upload.go index 7be1764..b61c1ed 100644 --- a/internal/step_release_upload.go +++ b/internal/step_release_upload.go @@ -4,6 +4,8 @@ import ( "context" "fmt" "os" + "strconv" + "strings" "github.com/google/go-github/v69/github" @@ -27,12 +29,13 @@ type releaseUploadStep struct { } type releaseUploadConfig struct { - Owner string `yaml:"owner"` - Repo string `yaml:"repo"` - ReleaseID int64 `yaml:"release_id"` - File string `yaml:"file"` - Name string `yaml:"name"` - Token string `yaml:"token"` + Owner string `yaml:"owner"` + Repo string `yaml:"repo"` + ReleaseID int64 `yaml:"release_id"` + ReleaseIDRaw string `yaml:"-"` // raw string for dynamic {{.field}} resolution + File string `yaml:"file"` + Name string `yaml:"name"` + Token string `yaml:"token"` } func newReleaseUploadStep(name string, raw map[string]any) (*releaseUploadStep, error) { @@ -52,8 +55,20 @@ func newReleaseUploadStep(name string, raw map[string]any) (*releaseUploadStep, cfg.ReleaseID = v case float64: cfg.ReleaseID = int64(v) + case string: + if v != "" { + if strings.Contains(v, "{{") && strings.Contains(v, "}}") { + cfg.ReleaseIDRaw = v + } else { + n, err := strconv.ParseInt(v, 10, 64) + if err != nil { + return nil, fmt.Errorf("step.gh_release_upload %q: config.release_id is not a valid integer: %w", name, err) + } + cfg.ReleaseID = n + } + } } - if cfg.ReleaseID == 0 { + if cfg.ReleaseID == 0 && cfg.ReleaseIDRaw == "" { return nil, fmt.Errorf("step.gh_release_upload %q: config.release_id is required", name) } cfg.File, _ = raw["file"].(string) @@ -86,6 +101,20 @@ func (s *releaseUploadStep) Execute( assetName = filePath } + // Resolve release_id — may be a static int or a dynamic template reference. + releaseID := s.config.ReleaseID + if s.config.ReleaseIDRaw != "" { + resolved := resolveField(s.config.ReleaseIDRaw, triggerData, stepOutputs, current) + n, err := strconv.ParseInt(resolved, 10, 64) + if err != nil { + return errorResult(fmt.Sprintf("release_id resolved to non-integer value %q: %v", resolved, err)), nil + } + releaseID = n + } + if releaseID == 0 { + return errorResult("release_id resolved to zero — check pipeline context"), nil + } + f, err := os.Open(filePath) //nolint:gosec // G304: path from step config, trusted if err != nil { return errorResult(fmt.Sprintf("open file %q: %v", filePath, err)), nil @@ -98,7 +127,7 @@ func (s *releaseUploadStep) Execute( } client := NewSDKClient(token) - asset, _, err := client.GH.Repositories.UploadReleaseAsset(ctx, owner, repo, s.config.ReleaseID, + asset, _, err := client.GH.Repositories.UploadReleaseAsset(ctx, owner, repo, releaseID, &github.UploadOptions{Name: assetName}, f) if err != nil { diff --git a/plugin.json b/plugin.json index 3505736..4adb59e 100644 --- a/plugin.json +++ b/plugin.json @@ -41,7 +41,7 @@ {"key": "workflow", "type": "string", "description": "Workflow filename or ID (e.g. ci.yml)", "required": true}, {"key": "ref", "type": "string", "description": "Branch or tag reference to run the workflow on", "defaultValue": "main"}, {"key": "inputs", "type": "map", "description": "Optional workflow_dispatch input key/value pairs"}, - {"key": "token", "type": "string", "description": "GitHub personal access token with workflow scope", "sensitive": true} + {"key": "token", "type": "string", "description": "GitHub personal access token with workflow scope", "required": true, "sensitive": true} ], "outputs": [ {"key": "triggered", "type": "boolean", "description": "Whether the workflow run was successfully triggered"}, @@ -58,8 +58,8 @@ "configFields": [ {"key": "owner", "type": "string", "description": "GitHub repository owner", "required": true}, {"key": "repo", "type": "string", "description": "GitHub repository name", "required": true}, - {"key": "run_id", "type": "string", "description": "Workflow run ID (integer or template expression)", "required": true}, - {"key": "token", "type": "string", "description": "GitHub personal access token", "sensitive": true}, + {"key": "run_id", "type": "number", "description": "Workflow run ID (integer or template expression e.g. {{.steps.trigger.run_id}})", "required": true}, + {"key": "token", "type": "string", "description": "GitHub personal access token", "required": true, "sensitive": true}, {"key": "wait", "type": "boolean", "description": "Poll until the run reaches a terminal state", "defaultValue": false}, {"key": "poll_interval", "type": "duration", "description": "Interval between status polls when wait=true", "defaultValue": "10s"}, {"key": "timeout", "type": "duration", "description": "Maximum time to wait when wait=true", "defaultValue": "30m"} @@ -84,7 +84,7 @@ {"key": "conclusion", "type": "select", "description": "Check run conclusion (required when status=completed)", "options": ["success", "failure", "neutral", "cancelled", "skipped", "timed_out", "action_required"]}, {"key": "title", "type": "string", "description": "Check output title"}, {"key": "summary", "type": "string", "description": "Check output summary"}, - {"key": "token", "type": "string", "description": "GitHub personal access token", "sensitive": true} + {"key": "token", "type": "string", "description": "GitHub personal access token", "required": true, "sensitive": true} ], "outputs": [ {"key": "check_run_id", "type": "number", "description": "Check run ID"}, @@ -104,7 +104,7 @@ {"key": "title", "type": "string", "description": "Pull request title"}, {"key": "body", "type": "string", "description": "Pull request description"}, {"key": "draft", "type": "boolean", "description": "Whether to create as a draft PR", "defaultValue": false}, - {"key": "token", "type": "string", "description": "GitHub personal access token", "sensitive": true} + {"key": "token", "type": "string", "description": "GitHub personal access token", "required": true, "sensitive": true} ], "outputs": [ {"key": "number", "type": "number", "description": "Pull request number"}, @@ -123,7 +123,7 @@ {"key": "pr_number", "type": "number", "description": "Pull request number to merge", "required": true}, {"key": "commit_title", "type": "string", "description": "Merge commit title"}, {"key": "method", "type": "select", "description": "Merge method", "options": ["merge", "squash", "rebase"], "defaultValue": "merge"}, - {"key": "token", "type": "string", "description": "GitHub personal access token", "sensitive": true} + {"key": "token", "type": "string", "description": "GitHub personal access token", "required": true, "sensitive": true} ], "outputs": [ {"key": "merged", "type": "boolean", "description": "Whether the merge succeeded"}, @@ -140,7 +140,7 @@ {"key": "repo", "type": "string", "description": "GitHub repository name", "required": true}, {"key": "pr_number", "type": "number", "description": "Pull request number", "required": true}, {"key": "body", "type": "string", "description": "Comment text"}, - {"key": "token", "type": "string", "description": "GitHub personal access token", "sensitive": true} + {"key": "token", "type": "string", "description": "GitHub personal access token", "required": true, "sensitive": true} ], "outputs": [ {"key": "comment_id", "type": "number", "description": "Comment ID"}, @@ -157,7 +157,7 @@ {"key": "pr_number", "type": "number", "description": "Pull request number", "required": true}, {"key": "event", "type": "select", "description": "Review event type", "options": ["APPROVE", "REQUEST_CHANGES", "COMMENT"], "defaultValue": "COMMENT"}, {"key": "body", "type": "string", "description": "Review body text"}, - {"key": "token", "type": "string", "description": "GitHub personal access token", "sensitive": true} + {"key": "token", "type": "string", "description": "GitHub personal access token", "required": true, "sensitive": true} ], "outputs": [ {"key": "review_id", "type": "number", "description": "Review ID"}, @@ -176,7 +176,7 @@ {"key": "body", "type": "string", "description": "Issue body"}, {"key": "labels", "type": "array", "description": "Labels to attach to the issue"}, {"key": "assignees", "type": "array", "description": "GitHub usernames to assign to the issue"}, - {"key": "token", "type": "string", "description": "GitHub personal access token", "sensitive": true} + {"key": "token", "type": "string", "description": "GitHub personal access token", "required": true, "sensitive": true} ], "outputs": [ {"key": "number", "type": "number", "description": "Issue number"}, @@ -194,7 +194,7 @@ {"key": "repo", "type": "string", "description": "GitHub repository name", "required": true}, {"key": "issue_number", "type": "number", "description": "Issue number to close", "required": true}, {"key": "comment", "type": "string", "description": "Optional closing comment"}, - {"key": "token", "type": "string", "description": "GitHub personal access token", "sensitive": true} + {"key": "token", "type": "string", "description": "GitHub personal access token", "required": true, "sensitive": true} ], "outputs": [ {"key": "number", "type": "number", "description": "Issue number"}, @@ -212,7 +212,7 @@ {"key": "issue_number", "type": "number", "description": "Issue or pull request number", "required": true}, {"key": "add", "type": "array", "description": "Labels to add"}, {"key": "remove", "type": "array", "description": "Labels to remove"}, - {"key": "token", "type": "string", "description": "GitHub personal access token", "sensitive": true} + {"key": "token", "type": "string", "description": "GitHub personal access token", "required": true, "sensitive": true} ], "outputs": [ {"key": "added", "type": "array", "description": "Labels that were added"}, @@ -231,7 +231,7 @@ {"key": "body", "type": "string", "description": "Release notes / changelog"}, {"key": "draft", "type": "boolean", "description": "Whether to create as a draft release", "defaultValue": false}, {"key": "prerelease", "type": "boolean", "description": "Whether this is a pre-release", "defaultValue": false}, - {"key": "token", "type": "string", "description": "GitHub personal access token", "sensitive": true} + {"key": "token", "type": "string", "description": "GitHub personal access token", "required": true, "sensitive": true} ], "outputs": [ {"key": "release_id", "type": "number", "description": "Release ID"}, @@ -249,10 +249,10 @@ "configFields": [ {"key": "owner", "type": "string", "description": "GitHub repository owner", "required": true}, {"key": "repo", "type": "string", "description": "GitHub repository name", "required": true}, - {"key": "release_id", "type": "number", "description": "Release ID (from step.gh_release_create output)", "required": true}, + {"key": "release_id", "type": "number", "description": "Release ID (integer or template expression e.g. {{.steps.create_release.release_id}})", "required": true}, {"key": "file", "type": "filepath", "description": "Local path to the file to upload", "required": true}, {"key": "name", "type": "string", "description": "Display name for the release asset"}, - {"key": "token", "type": "string", "description": "GitHub personal access token", "sensitive": true} + {"key": "token", "type": "string", "description": "GitHub personal access token", "required": true, "sensitive": true} ], "outputs": [ {"key": "asset_id", "type": "number", "description": "Asset ID"}, @@ -270,7 +270,7 @@ {"key": "repo", "type": "string", "description": "GitHub repository name", "required": true}, {"key": "event_type", "type": "string", "description": "Custom event type name", "required": true}, {"key": "payload", "type": "map", "description": "Client payload data to include with the event"}, - {"key": "token", "type": "string", "description": "GitHub personal access token", "sensitive": true} + {"key": "token", "type": "string", "description": "GitHub personal access token", "required": true, "sensitive": true} ], "outputs": [ {"key": "dispatched", "type": "boolean", "description": "Whether the event was dispatched"}, @@ -290,7 +290,7 @@ {"key": "environment", "type": "string", "description": "Target deployment environment", "defaultValue": "production"}, {"key": "description", "type": "string", "description": "Deployment description"}, {"key": "auto_merge", "type": "boolean", "description": "Auto-merge the default branch before deploying", "defaultValue": false}, - {"key": "token", "type": "string", "description": "GitHub personal access token", "sensitive": true} + {"key": "token", "type": "string", "description": "GitHub personal access token", "required": true, "sensitive": true} ], "outputs": [ {"key": "deployment_id", "type": "number", "description": "Deployment ID"}, @@ -306,10 +306,10 @@ "description": "Creates or updates a repository secret, encrypting the value with the repo's public key.", "configFields": [ {"key": "owner", "type": "string", "description": "GitHub repository owner or organisation name", "required": true}, - {"key": "repo", "type": "string", "description": "Repository name (omit for org-level secrets)"}, + {"key": "repo", "type": "string", "description": "Repository name", "required": true}, {"key": "name", "type": "string", "description": "Secret name", "required": true}, {"key": "value", "type": "string", "description": "Secret value (supports env var references)", "sensitive": true}, - {"key": "token", "type": "string", "description": "GitHub personal access token with repo secrets permission", "sensitive": true} + {"key": "token", "type": "string", "description": "GitHub personal access token with repo secrets permission", "required": true, "sensitive": true} ], "outputs": [ {"key": "name", "type": "string", "description": "Secret name"}, @@ -325,7 +325,7 @@ "configFields": [ {"key": "query", "type": "string", "description": "GraphQL query string", "required": true}, {"key": "variables", "type": "map", "description": "GraphQL query variables"}, - {"key": "token", "type": "string", "description": "GitHub personal access token", "sensitive": true} + {"key": "token", "type": "string", "description": "GitHub personal access token", "required": true, "sensitive": true} ], "outputs": [ {"key": "data", "type": "map", "description": "GraphQL response data object"}, From 5945a1bdee1b30e3e01c5c018e93902c9ffe61ac Mon Sep 17 00:00:00 2001 From: Jonathan Langevin Date: Wed, 6 May 2026 01:31:20 -0400 Subject: [PATCH 5/7] Change CI runner to ubuntu-latest --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a22e1db..338c912 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,7 +6,7 @@ on: branches: [main, master] jobs: test: - runs-on: [self-hosted, Linux, X64] + runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: actions/setup-go@v6 @@ -20,7 +20,7 @@ jobs: strict-contracts: name: Validate strict plugin contracts - runs-on: [self-hosted, Linux, X64] + runs-on: ubuntu-latest permissions: contents: read steps: From 1df7e696b0617922ad1a1efddb48c5979cd2f83c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 6 May 2026 05:53:20 +0000 Subject: [PATCH 6/7] Address remaining review comments: fix type/description issues, re-add provider, fix CI informational step, add field-level contract tests Agent-Logs-Url: https://github.com/GoCodeAlone/workflow-plugin-github/sessions/c9cbf541-38c2-4769-b1b9-80f1ca0007ac Co-authored-by: intel352 <77607+intel352@users.noreply.github.com> --- .github/workflows/ci.yml | 5 +- internal/schemas.go | 7 +++ internal/schemas_test.go | 118 ++++++++++++++++++++++++++++++++++++++- plugin.json | 10 ++-- 4 files changed, 133 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 338c912..b4ea437 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,7 +41,10 @@ jobs: run: | # wfctl validates registry-format manifests; strict contract schema coverage is enforced # by the Go tests above. This step runs informational validation and logs the result. - go run github.com/GoCodeAlone/workflow/cmd/wfctl@v0.3.56 plugin validate --file plugin.json 2>&1; wfctl_exit=$? + set +e + go run github.com/GoCodeAlone/workflow/cmd/wfctl@v0.3.56 plugin validate --file plugin.json 2>&1 + wfctl_exit=$? + set -e echo "wfctl validation exit code: ${wfctl_exit}" env: GOPRIVATE: github.com/GoCodeAlone/* diff --git a/internal/schemas.go b/internal/schemas.go index ea740ca..b33a01e 100644 --- a/internal/schemas.go +++ b/internal/schemas.go @@ -16,6 +16,13 @@ func (p *githubPlugin) ModuleSchemas() []sdk.ModuleSchemaData { Category: "github", Description: "Receives GitHub webhook events via HTTP, verifies HMAC-SHA256 signatures, and publishes normalised GitEvent messages to a configurable topic.", ConfigFields: []sdk.ConfigField{ + { + Name: "provider", + Type: "string", + Description: "Webhook provider identifier. Accepted for backward compatibility; the module always publishes events with provider 'github'.", + DefaultValue: "github", + Required: false, + }, { Name: "secret", Type: "string", diff --git a/internal/schemas_test.go b/internal/schemas_test.go index 6cf26f0..06dc862 100644 --- a/internal/schemas_test.go +++ b/internal/schemas_test.go @@ -65,7 +65,7 @@ func TestModuleSchemas(t *testing.T) { for _, f := range webhook.ConfigFields { webhookFieldNames[f.Name] = true } - for _, want := range []string{"secret", "events", "topic"} { + for _, want := range []string{"provider", "secret", "events", "topic"} { if !webhookFieldNames[want] { t.Errorf("git.webhook schema: required config field %q is missing", want) } @@ -206,6 +206,122 @@ func TestPluginStepSchemasJSON(t *testing.T) { } } +// TestStepSchemaFieldContracts verifies critical field-level properties across +// step schemas in plugin.json: token required/sensitive, template-capable fields +// typed as string, and key required flags. +func TestStepSchemaFieldContracts(t *testing.T) { + data, err := os.ReadFile("../plugin.json") + if err != nil { + t.Fatalf("plugin.json not found: %v", err) + } + + type configField struct { + Key string `json:"key"` + Type string `json:"type"` + Required bool `json:"required"` + Sensitive bool `json:"sensitive"` + DefaultValue any `json:"defaultValue"` + } + type stepSchema struct { + Type string `json:"type"` + ConfigFields []configField `json:"configFields"` + } + var manifest struct { + StepSchemas []stepSchema `json:"stepSchemas"` + } + if err := json.Unmarshal(data, &manifest); err != nil { + t.Fatalf("parse plugin.json: %v", err) + } + + // Index schemas by type for easy lookup. + byType := make(map[string]stepSchema, len(manifest.StepSchemas)) + for _, s := range manifest.StepSchemas { + byType[s.Type] = s + } + fieldsByKey := func(s stepSchema) map[string]configField { + m := make(map[string]configField, len(s.ConfigFields)) + for _, f := range s.ConfigFields { + m[f.Key] = f + } + return m + } + + // Every step schema with a token field must mark it required and sensitive. + for _, s := range manifest.StepSchemas { + fields := fieldsByKey(s) + if tok, ok := fields["token"]; ok { + if !tok.Required { + t.Errorf("%s: token field must be required=true (step fails at runtime without it)", s.Type) + } + if !tok.Sensitive { + t.Errorf("%s: token field must be sensitive=true", s.Type) + } + } + } + + // Fields that accept both numeric literals and template expressions must be + // declared as string so schema-aware validators accept template syntax. + templateCapableFields := map[string]string{ + "step.gh_action_status": "run_id", + "step.gh_release_upload": "release_id", + } + for stepType, fieldKey := range templateCapableFields { + s, ok := byType[stepType] + if !ok { + t.Errorf("%s: schema not found", stepType) + continue + } + fields := fieldsByKey(s) + f, ok := fields[fieldKey] + if !ok { + t.Errorf("%s: field %q not found in schema", stepType, fieldKey) + continue + } + if f.Type != "string" { + t.Errorf("%s: field %q type should be %q (supports template expressions), got %q", + stepType, fieldKey, "string", f.Type) + } + if !f.Required { + t.Errorf("%s: field %q should be required=true", stepType, fieldKey) + } + } + + // Dynamic string fields that also accept template expressions must not be + // declared as select (which would prevent template expressions). + noSelectFields := map[string]string{ + "step.gh_pr_merge": "method", + "step.gh_pr_review": "event", + } + for stepType, fieldKey := range noSelectFields { + s, ok := byType[stepType] + if !ok { + t.Errorf("%s: schema not found", stepType) + continue + } + fields := fieldsByKey(s) + f, ok := fields[fieldKey] + if !ok { + t.Errorf("%s: field %q not found in schema", stepType, fieldKey) + continue + } + if f.Type == "select" { + t.Errorf("%s: field %q should not be type 'select' — resolveField supports templates so the type must be 'string'", stepType, fieldKey) + } + } + + // gh_secret_set: repo must be required (no org-secret path exists). + if s, ok := byType["step.gh_secret_set"]; ok { + fields := fieldsByKey(s) + if repo, ok := fields["repo"]; ok { + if !repo.Required { + t.Errorf("step.gh_secret_set: repo field must be required=true (only repo secrets are supported)") + } + } else { + t.Errorf("step.gh_secret_set: repo field missing from schema") + } + } +} + // TestPluginManifestEngineValidation verifies that plugin.json is parseable as a // workflow engine PluginManifest and that Validate() passes required-field checks. func TestPluginManifestEngineValidation(t *testing.T) { diff --git a/plugin.json b/plugin.json index 4adb59e..b0ebffb 100644 --- a/plugin.json +++ b/plugin.json @@ -58,7 +58,7 @@ "configFields": [ {"key": "owner", "type": "string", "description": "GitHub repository owner", "required": true}, {"key": "repo", "type": "string", "description": "GitHub repository name", "required": true}, - {"key": "run_id", "type": "number", "description": "Workflow run ID (integer or template expression e.g. {{.steps.trigger.run_id}})", "required": true}, + {"key": "run_id", "type": "string", "description": "Workflow run ID (numeric literal or template expression e.g. {{.steps.trigger_step.run_id}})", "required": true}, {"key": "token", "type": "string", "description": "GitHub personal access token", "required": true, "sensitive": true}, {"key": "wait", "type": "boolean", "description": "Poll until the run reaches a terminal state", "defaultValue": false}, {"key": "poll_interval", "type": "duration", "description": "Interval between status polls when wait=true", "defaultValue": "10s"}, @@ -74,7 +74,7 @@ { "type": "step.gh_create_check", "plugin": "workflow-plugin-github", - "description": "Creates or updates a GitHub Check Run (status check) on a specific commit.", + "description": "Creates a GitHub Check Run (status check) on a specific commit.", "configFields": [ {"key": "owner", "type": "string", "description": "GitHub repository owner", "required": true}, {"key": "repo", "type": "string", "description": "GitHub repository name", "required": true}, @@ -122,7 +122,7 @@ {"key": "repo", "type": "string", "description": "GitHub repository name", "required": true}, {"key": "pr_number", "type": "number", "description": "Pull request number to merge", "required": true}, {"key": "commit_title", "type": "string", "description": "Merge commit title"}, - {"key": "method", "type": "select", "description": "Merge method", "options": ["merge", "squash", "rebase"], "defaultValue": "merge"}, + {"key": "method", "type": "string", "description": "Merge method: merge, squash, or rebase (also accepts template expressions)", "defaultValue": "merge"}, {"key": "token", "type": "string", "description": "GitHub personal access token", "required": true, "sensitive": true} ], "outputs": [ @@ -155,7 +155,7 @@ {"key": "owner", "type": "string", "description": "GitHub repository owner", "required": true}, {"key": "repo", "type": "string", "description": "GitHub repository name", "required": true}, {"key": "pr_number", "type": "number", "description": "Pull request number", "required": true}, - {"key": "event", "type": "select", "description": "Review event type", "options": ["APPROVE", "REQUEST_CHANGES", "COMMENT"], "defaultValue": "COMMENT"}, + {"key": "event", "type": "string", "description": "Review event type: APPROVE, REQUEST_CHANGES, or COMMENT (also accepts template expressions)", "defaultValue": "COMMENT"}, {"key": "body", "type": "string", "description": "Review body text"}, {"key": "token", "type": "string", "description": "GitHub personal access token", "required": true, "sensitive": true} ], @@ -249,7 +249,7 @@ "configFields": [ {"key": "owner", "type": "string", "description": "GitHub repository owner", "required": true}, {"key": "repo", "type": "string", "description": "GitHub repository name", "required": true}, - {"key": "release_id", "type": "number", "description": "Release ID (integer or template expression e.g. {{.steps.create_release.release_id}})", "required": true}, + {"key": "release_id", "type": "string", "description": "Release ID (numeric literal or template expression e.g. {{.steps.create_release.release_id}})", "required": true}, {"key": "file", "type": "filepath", "description": "Local path to the file to upload", "required": true}, {"key": "name", "type": "string", "description": "Display name for the release asset"}, {"key": "token", "type": "string", "description": "GitHub personal access token", "required": true, "sensitive": true} From fcf3cd640c01bc1b6b946a619982b2dcde5e86a4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 6 May 2026 05:55:52 +0000 Subject: [PATCH 7/7] ci: add permissions: contents: read to test job to fix CodeQL finding Agent-Logs-Url: https://github.com/GoCodeAlone/workflow-plugin-github/sessions/fd1e783c-d22a-4848-8ff1-6b2c7b9176e2 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 b4ea437..70a28a7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,8 @@ on: jobs: test: runs-on: ubuntu-latest + permissions: + contents: read steps: - uses: actions/checkout@v6 - uses: actions/setup-go@v6