From 8b83fa3510101dbc2357c51d9f8f83fd0c8a504d Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Wed, 27 May 2026 02:02:35 -0400 Subject: [PATCH 1/2] feat: support descriptor-only message contracts --- .github/workflows/create-release.yml | 15 +- cmd/wfctl/audit.go | 5 +- cmd/wfctl/editor_bundle.go | 12 ++ cmd/wfctl/editor_bundle_test.go | 70 ++++++ cmd/wfctl/plugin.go | 3 + cmd/wfctl/plugin_audit.go | 199 ++++++++++++++++-- cmd/wfctl/plugin_audit_test.go | 56 +++++ cmd/wfctl/plugin_validate_contract.go | 112 +++++++++- cmd/wfctl/plugin_validate_contract_test.go | 74 +++++++ .../message-contract/descriptors/message.pb | Bin 0 -> 499 bytes .../message-contract/plugin.contracts.json | 19 ++ .../plugins/message-contract/plugin.json | 7 + .../message-runtime-contract/.goreleaser.yaml | 5 + .../cmd/plugin/main.go | 30 +++ .../descriptors/message.pb | Bin 0 -> 499 bytes .../plugin.contracts.json | 19 ++ .../message-runtime-contract/plugin.json | 12 ++ .../plugin.contracts.json | 10 + .../plugins/unknown-contract-kind/plugin.json | 6 + plugin/external/message_contract_test.go | 50 +++++ plugin/external/proto/plugin.pb.go | 92 ++++++-- plugin/external/proto/plugin.proto | 7 + plugin/external/sdk/contracts.go | 63 ++++++ plugin/external/sdk/contracts_test.go | 38 ++++ plugin/sdk/contracts.go | 82 ++++++++ plugin/sdk/generator.go | 64 +++--- plugin/sdk/generator_test.go | 120 +++++++++++ schema/editor_bundle.go | 48 ++++- schema/editor_bundle_test.go | 47 +++++ scripts/resolve-gh-run-for-ref.sh | 104 +++++++++ 30 files changed, 1291 insertions(+), 78 deletions(-) create mode 100644 cmd/wfctl/testdata/plugins/message-contract/descriptors/message.pb create mode 100644 cmd/wfctl/testdata/plugins/message-contract/plugin.contracts.json create mode 100644 cmd/wfctl/testdata/plugins/message-contract/plugin.json create mode 100644 cmd/wfctl/testdata/plugins/message-runtime-contract/.goreleaser.yaml create mode 100644 cmd/wfctl/testdata/plugins/message-runtime-contract/cmd/plugin/main.go create mode 100644 cmd/wfctl/testdata/plugins/message-runtime-contract/descriptors/message.pb create mode 100644 cmd/wfctl/testdata/plugins/message-runtime-contract/plugin.contracts.json create mode 100644 cmd/wfctl/testdata/plugins/message-runtime-contract/plugin.json create mode 100644 cmd/wfctl/testdata/plugins/unknown-contract-kind/plugin.contracts.json create mode 100644 cmd/wfctl/testdata/plugins/unknown-contract-kind/plugin.json create mode 100644 plugin/external/message_contract_test.go create mode 100644 plugin/sdk/contracts.go create mode 100755 scripts/resolve-gh-run-for-ref.sh diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index 094bb0e8..e58d2724 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -27,6 +27,7 @@ jobs: runs-on: ubuntu-latest outputs: tag: ${{ steps.next_version.outputs.tag }} + release_tag: ${{ steps.next_version.outputs.tag }} steps: - name: Check out code @@ -81,12 +82,24 @@ jobs: git tag "${{ steps.next_version.outputs.tag }}" git push origin "${{ steps.next_version.outputs.tag }}" + - name: Write release metadata + run: | + mkdir -p release-metadata + printf '%s\n' "${{ steps.next_version.outputs.tag }}" > release-metadata/release-tag.txt + + - name: Upload release metadata + uses: actions/upload-artifact@v4 + with: + name: release-metadata + path: release-metadata/release-tag.txt + if-no-files-found: error + release: name: Build and publish release needs: tag uses: ./.github/workflows/release.yml with: - tag_name: ${{ needs.tag.outputs.tag }} + tag_name: ${{ needs.tag.outputs.release_tag }} secrets: repo_dispatch_token: ${{ secrets.repo_dispatch_token }} permissions: diff --git a/cmd/wfctl/audit.go b/cmd/wfctl/audit.go index fde14055..bf5dcc55 100644 --- a/cmd/wfctl/audit.go +++ b/cmd/wfctl/audit.go @@ -284,11 +284,12 @@ func renderPluginAuditReport(out io.Writer, report pluginAuditReport) { } func pluginContractCoverageSummary(coverage pluginContractCoverage) string { - return fmt.Sprintf("module %d/%d strict, step %d/%d strict, trigger %d/%d strict, service method %d/%d strict", + return fmt.Sprintf("module %d/%d strict, step %d/%d strict, trigger %d/%d strict, service method %d/%d strict, message %d/%d strict", coverage.Modules.Strict, coverage.Modules.Total, coverage.Steps.Strict, coverage.Steps.Total, coverage.Triggers.Strict, coverage.Triggers.Total, - coverage.ServiceMethods.Strict, coverage.ServiceMethods.Total) + coverage.ServiceMethods.Strict, coverage.ServiceMethods.Total, + coverage.Messages.Strict, coverage.Messages.Total) } func pluginFindingCodes(findings []planFinding) []string { diff --git a/cmd/wfctl/editor_bundle.go b/cmd/wfctl/editor_bundle.go index 44a8454d..cbc86cdd 100644 --- a/cmd/wfctl/editor_bundle.go +++ b/cmd/wfctl/editor_bundle.go @@ -292,6 +292,10 @@ func editorBundleContractIDFromPluginDescriptor(descriptor *pluginContractDescri return id, nil } return "", fmt.Errorf("malformed service_method contract descriptor: serviceName and method are required when moduleType or serviceName is set") + case "message": + if typ := descriptor.contractType(kind); typ != "" { + return "message:" + typ, nil + } } return "", nil } @@ -349,6 +353,14 @@ func contractDescriptorFromPluginDescriptor(descriptor *pluginContractDescriptor if _, ok := editorBundleServiceContractID(contract.ModuleType, contract.ServiceName, contract.Method); !ok { return nil, fmt.Errorf("malformed service_method contract descriptor: serviceName and method are required when moduleType or serviceName is set") } + case "message": + contract.Kind = pb.ContractKind_CONTRACT_KIND_MESSAGE + contract.ContractType = descriptor.ContractType + contract.ProtoPackage = descriptor.ProtoPackage + contract.MessageNames = append([]string(nil), descriptor.MessageNames...) + contract.GoImportPath = descriptor.GoImportPath + contract.SchemaDigest = descriptor.SchemaDigest + contract.ProtocolVersion = descriptor.ProtocolVersion default: return nil, nil } diff --git a/cmd/wfctl/editor_bundle_test.go b/cmd/wfctl/editor_bundle_test.go index 8258c7cb..934a78b9 100644 --- a/cmd/wfctl/editor_bundle_test.go +++ b/cmd/wfctl/editor_bundle_test.go @@ -111,6 +111,44 @@ func TestRunEditorBundleLoadsPluginContractDescriptorSetReference(t *testing.T) } } +func TestRunEditorBundleLoadsMessageContractDescriptor(t *testing.T) { + dir := t.TempDir() + outPath := filepath.Join(dir, "editor-bundle.json") + + if err := runEditorBundle([]string{"--registry=false", "--plugin-dir", "testdata/plugins/message-contract", "--output", outPath}); err != nil { + t.Fatalf("editor-bundle failed: %v", err) + } + + data, err := os.ReadFile(outPath) + if err != nil { + t.Fatalf("read output: %v", err) + } + var bundle struct { + Contracts map[string]struct { + DescriptorSetRef string `json:"descriptorSetRef"` + ProtoPackage string `json:"protoPackage"` + MessageNames []string `json:"messageNames"` + ProtocolVersion string `json:"protocolVersion"` + } `json:"contracts"` + } + if err := json.Unmarshal(data, &bundle); err != nil { + t.Fatalf("bundle is not valid JSON: %v", err) + } + contract := bundle.Contracts["message:compute.network_audit_evidence.v1"] + if contract.DescriptorSetRef != "descriptors/message.pb" { + t.Fatalf("descriptorSetRef = %q", contract.DescriptorSetRef) + } + if contract.ProtoPackage != "workflow_plugin_compute_core.protocol.v1" { + t.Fatalf("protoPackage = %q", contract.ProtoPackage) + } + if len(contract.MessageNames) != 2 || contract.MessageNames[0] != "NetworkAuditRecord" { + t.Fatalf("messageNames = %v", contract.MessageNames) + } + if contract.ProtocolVersion != "compute.v1alpha1" { + t.Fatalf("protocolVersion = %q", contract.ProtocolVersion) + } +} + func TestRunEditorBundleRejectsMalformedPluginContractDescriptors(t *testing.T) { dir := t.TempDir() pluginDir := filepath.Join(dir, "workflow-plugin-bad-contracts") @@ -298,6 +336,26 @@ func TestRunEditorBundlePreservesPerContractDescriptorSetReferences(t *testing.T "input": "workflow.two.Input", "output": "workflow.two.Output", "descriptorSetRef": "proto/two.pb" + }, + { + "kind": "message", + "contractType": "message.one", + "mode": "strict", + "protoPackage": "workflow.one", + "messageNames": ["Event"], + "schemaDigest": "sha256:one", + "protocolVersion": "v1", + "descriptorSetRef": "proto/message-one.pb" + }, + { + "kind": "message", + "contractType": "message.two", + "mode": "strict", + "protoPackage": "workflow.two", + "messageNames": ["Event"], + "schemaDigest": "sha256:two", + "protocolVersion": "v1", + "descriptorSetRef": "proto/message-two.pb" } ] }`), 0644); err != nil { @@ -339,6 +397,18 @@ func TestRunEditorBundlePreservesPerContractDescriptorSetReferences(t *testing.T if got := bundle.Messages["workflow.two.Input"].DescriptorSetRef; got != "proto/two.pb" { t.Fatalf("workflow.two.Input descriptorSetRef = %q", got) } + if got := bundle.Contracts["message:message.one"].DescriptorSetRef; got != "proto/message-one.pb" { + t.Fatalf("message.one descriptorSetRef = %q", got) + } + if got := bundle.Contracts["message:message.two"].DescriptorSetRef; got != "proto/message-two.pb" { + t.Fatalf("message.two descriptorSetRef = %q", got) + } + if got := bundle.Messages["workflow.one.Event"].DescriptorSetRef; got != "proto/message-one.pb" { + t.Fatalf("workflow.one.Event descriptorSetRef = %q", got) + } + if got := bundle.Messages["workflow.two.Event"].DescriptorSetRef; got != "proto/message-two.pb" { + t.Fatalf("workflow.two.Event descriptorSetRef = %q", got) + } if bundle.DescriptorSets["proto/one.pb"].ExternalRef != "proto/one.pb" { t.Fatalf("descriptor set one reference missing: %+v", bundle.DescriptorSets) } diff --git a/cmd/wfctl/plugin.go b/cmd/wfctl/plugin.go index f07ee54f..8b3d5337 100644 --- a/cmd/wfctl/plugin.go +++ b/cmd/wfctl/plugin.go @@ -35,6 +35,8 @@ func runPlugin(args []string) error { return runPluginRemove(args[1:]) case "validate": return runPluginValidate(args[1:]) + case "audit": + return runPluginAudit(args[1:]) case "validate-contract": return runPluginValidateContract(args[1:]) case "verify-capabilities": @@ -69,6 +71,7 @@ Subcommands: update Update an installed plugin to its latest version remove Uninstall a plugin (also removes from manifest + lockfile) validate Validate a plugin manifest from the registry or a local file + audit Audit a single plugin source directory validate-contract Validate a plugin source directory against the release contract (workflow#758) verify-capabilities Spawn plugin binary, verify runtime GetManifest matches plugin.json registry-sync Sync registry manifest versions/capabilities from upstream release tags; subcommands: core, readme (workflow#762) diff --git a/cmd/wfctl/plugin_audit.go b/cmd/wfctl/plugin_audit.go index ea28c00a..7e119b30 100644 --- a/cmd/wfctl/plugin_audit.go +++ b/cmd/wfctl/plugin_audit.go @@ -2,7 +2,9 @@ package main import ( "encoding/json" + "flag" "fmt" + "io" "os" "path/filepath" "sort" @@ -27,8 +29,67 @@ func auditPluginRepo(path string) pluginAuditResult { return auditPluginRepoWithOptions(path, pluginAuditOptions{}) } +func runPluginAudit(args []string) error { + return runPluginAuditWithOutput(args, os.Stdout) +} + +func runPluginAuditWithOutput(args []string, out io.Writer) error { + fs := flag.NewFlagSet("plugin audit", flag.ContinueOnError) + fs.SetOutput(out) + jsonOut := fs.Bool("json", false, "Write JSON output") + strict := fs.Bool("strict", false, "Treat warnings and errors as failures") + strictContracts := fs.Bool("strict-contracts", false, "Fail when advertised plugin types lack strict contract descriptors") + var requiredKinds multiStringFlag + fs.Var(&requiredKinds, "require-contract-kind", "Require a static contract kind in plugin.contracts.json. Repeatable or comma-separated.") + fs.Usage = func() { + fmt.Fprintf(fs.Output(), `Usage: wfctl plugin audit [options] + +Audit a single plugin source directory. + +Options: +`) + fs.PrintDefaults() + } + if err := fs.Parse(args); err != nil { + return err + } + if fs.NArg() != 1 { + fs.Usage() + return fmt.Errorf("exactly one argument required") + } + + result := auditPluginRepoWithOptions(fs.Arg(0), pluginAuditOptions{ + StrictContracts: *strictContracts, + RequireContractKinds: []string(requiredKinds), + }) + report := pluginAuditReport{ + Plugins: []pluginAuditResult{result}, + Findings: append([]planFinding(nil), result.Findings...), + Summary: summarizePluginAudit([]pluginAuditResult{result}), + } + + if *jsonOut { + enc := json.NewEncoder(out) + enc.SetIndent("", " ") + if err := enc.Encode(report); err != nil { + return err + } + } else { + renderPluginAuditReport(out, report) + } + + if *strict && (report.Summary.Errors > 0 || report.Summary.Warnings > 0) { + return fmt.Errorf("%d plugin audit finding(s) found", report.Summary.Errors+report.Summary.Warnings) + } + if *strictContracts && countPluginContractFindings(report.Findings) > 0 { + return fmt.Errorf("%d plugin contract audit finding(s) found", countPluginContractFindings(report.Findings)) + } + return nil +} + type pluginAuditOptions struct { - StrictContracts bool + StrictContracts bool + RequireContractKinds []string } type pluginContractCoverage struct { @@ -36,6 +97,7 @@ type pluginContractCoverage struct { Steps pluginContractKindCoverage `json:"steps"` Triggers pluginContractKindCoverage `json:"triggers"` ServiceMethods pluginContractKindCoverage `json:"serviceMethods"` + Messages pluginContractKindCoverage `json:"messages"` } type pluginContractKindCoverage struct { @@ -52,18 +114,24 @@ type pluginContractDescriptorFile struct { } type pluginContractDescriptor struct { - Kind string `json:"kind"` - Type string `json:"type"` - Mode string `json:"mode"` - Config string `json:"config,omitempty"` - Input string `json:"input,omitempty"` - Output string `json:"output,omitempty"` - ModuleType string `json:"moduleType,omitempty"` - StepType string `json:"stepType,omitempty"` - TriggerType string `json:"triggerType,omitempty"` - ServiceName string `json:"serviceName,omitempty"` - Method string `json:"method,omitempty"` - DescriptorSetRef string `json:"descriptorSetRef,omitempty"` + Kind string `json:"kind"` + Type string `json:"type"` + Mode string `json:"mode"` + Config string `json:"config,omitempty"` + Input string `json:"input,omitempty"` + Output string `json:"output,omitempty"` + ModuleType string `json:"moduleType,omitempty"` + StepType string `json:"stepType,omitempty"` + TriggerType string `json:"triggerType,omitempty"` + ServiceName string `json:"serviceName,omitempty"` + Method string `json:"method,omitempty"` + DescriptorSetRef string `json:"descriptorSetRef,omitempty"` + ContractType string `json:"contractType,omitempty"` + ProtoPackage string `json:"protoPackage,omitempty"` + MessageNames []string `json:"messageNames,omitempty"` + GoImportPath string `json:"goImportPath,omitempty"` + SchemaDigest string `json:"schemaDigest,omitempty"` + ProtocolVersion string `json:"protocolVersion,omitempty"` } func (d *pluginContractDescriptor) UnmarshalJSON(data []byte) error { @@ -83,6 +151,12 @@ func (d *pluginContractDescriptor) UnmarshalJSON(data []byte) error { d.ServiceName = firstStringField(raw, "serviceName", "service_name") d.Method = firstStringField(raw, "method") d.DescriptorSetRef = firstStringField(raw, "descriptorSetRef", "descriptor_set_ref") + d.ContractType = firstStringField(raw, "contractType", "contract_type") + d.ProtoPackage = firstStringField(raw, "protoPackage", "proto_package") + d.MessageNames = stringSliceFromAny(firstAnyField(raw, "messageNames", "message_names")) + d.GoImportPath = firstStringField(raw, "goImportPath", "go_import_path") + d.SchemaDigest = firstStringField(raw, "schemaDigest", "schema_digest") + d.ProtocolVersion = firstStringField(raw, "protocolVersion", "protocol_version") return nil } @@ -233,9 +307,19 @@ func addPluginContractFindings(result *pluginAuditResult, manifest map[string]an result.Findings = append(result.Findings, findings...) byKindType := make(map[string]pluginContractDescriptor) + kindCounts := make(map[string]pluginContractKindCoverage) for i := range descriptors { descriptor := descriptors[i] kind := normalizePluginContractKind(descriptor.Kind) + if kind != "" && !isKnownPluginContractKind(kind) { + result.Findings = append(result.Findings, planFinding{ + Path: result.ContractFile, + Level: strictContractFindingLevel(opts), + Code: "unknown_contract_kind", + Message: fmt.Sprintf("unknown contract kind %q", descriptor.Kind), + }) + continue + } typ := strings.TrimSpace(descriptor.contractType(kind)) if kind == "" || typ == "" { continue @@ -243,12 +327,74 @@ func addPluginContractFindings(result *pluginAuditResult, manifest map[string]an descriptor.Kind = kind descriptor.Mode = normalizePluginContractMode(descriptor.Mode) byKindType[kind+"\x00"+typ] = descriptor + coverage := kindCounts[kind] + coverage.Total++ + if descriptor.Mode == "strict" { + coverage.Strict++ + } else { + coverage.Legacy++ + } + kindCounts[kind] = coverage + if kind == "message" { + addMessageContractDescriptorFindings(result, descriptor, opts) + } } result.ContractCoverage.Modules = addPluginContractKindFindings(result, "module", advertised.Modules, byKindType, opts) result.ContractCoverage.Steps = addPluginContractKindFindings(result, "step", advertised.Steps, byKindType, opts) result.ContractCoverage.Triggers = addPluginContractKindFindings(result, "trigger", advertised.Triggers, byKindType, opts) result.ContractCoverage.ServiceMethods = addPluginContractKindFindings(result, "service_method", advertised.ServiceMethods, byKindType, opts) + result.ContractCoverage.Messages = kindCounts["message"] + addRequiredContractKindFindings(result, kindCounts, opts) +} + +func addMessageContractDescriptorFindings(result *pluginAuditResult, descriptor pluginContractDescriptor, opts pluginAuditOptions) { + for field, value := range map[string]string{ + "contractType": descriptor.ContractType, + "protoPackage": descriptor.ProtoPackage, + "schemaDigest": descriptor.SchemaDigest, + "protocolVersion": descriptor.ProtocolVersion, + } { + if strings.TrimSpace(value) == "" { + result.Findings = append(result.Findings, planFinding{ + Path: result.ContractFile, + Level: strictContractFindingLevel(opts), + Code: "invalid_message_contract_descriptor", + Message: fmt.Sprintf("message contract missing %s", field), + }) + } + } + if len(descriptor.MessageNames) == 0 { + result.Findings = append(result.Findings, planFinding{ + Path: result.ContractFile, + Level: strictContractFindingLevel(opts), + Code: "invalid_message_contract_descriptor", + Message: "message contract missing messageNames", + }) + } +} + +func addRequiredContractKindFindings(result *pluginAuditResult, kindCounts map[string]pluginContractKindCoverage, opts pluginAuditOptions) { + for _, required := range opts.RequireContractKinds { + kind := normalizePluginContractKind(required) + if !isKnownPluginContractKind(kind) { + result.Findings = append(result.Findings, planFinding{ + Path: filepath.Join(result.RepoPath, "plugin.contracts.json"), + Level: strictContractFindingLevel(opts), + Code: "unknown_required_contract_kind", + Message: fmt.Sprintf("unknown required contract kind %q", required), + }) + continue + } + if kindCounts[kind].Total == 0 { + result.Findings = append(result.Findings, planFinding{ + Path: filepath.Join(result.RepoPath, "plugin.contracts.json"), + Level: strictContractFindingLevel(opts), + Code: "missing_required_contract_kind", + Message: fmt.Sprintf("required contract kind %q is not declared", kind), + }) + } + } } func loadPluginContractDescriptors(repoPath string, manifest map[string]any, opts pluginAuditOptions) ([]pluginContractDescriptor, string, bool, []planFinding) { @@ -374,6 +520,8 @@ func (d pluginContractDescriptor) contractType(kind string) string { return d.ServiceName + "/" + d.Method } return d.Method + case "message": + return d.ContractType default: return "" } @@ -467,11 +615,22 @@ func normalizePluginContractKind(kind string) string { return "trigger" case "service_method", "service-method", "servicemethod", "servicemethods", "service", "contract_kind_service": return "service_method" + case "message", "messages", "contract_kind_message": + return "message" default: return strings.ToLower(strings.TrimSpace(kind)) } } +func isKnownPluginContractKind(kind string) bool { + switch kind { + case "module", "step", "trigger", "service_method", "message": + return true + default: + return false + } +} + func normalizePluginContractMode(mode string) string { switch strings.ToLower(strings.TrimSpace(mode)) { case "strict", "strict_contract", "typed", "strict_proto", "contract_mode_strict_proto": @@ -498,7 +657,10 @@ func countPluginContractFindings(findings []planFinding) int { func isPluginContractFinding(finding planFinding) bool { return strings.Contains(finding.Code, "contract_descriptor") || finding.Code == "read_plugin_contract_descriptors" || - finding.Code == "invalid_plugin_contract_descriptors" + finding.Code == "invalid_plugin_contract_descriptors" || + finding.Code == "unknown_contract_kind" || + finding.Code == "unknown_required_contract_kind" || + finding.Code == "missing_required_contract_kind" } func pluginContractKindLabel(kind string) string { @@ -526,6 +688,15 @@ func firstStringField(values map[string]any, keys ...string) string { return "" } +func firstAnyField(values map[string]any, keys ...string) any { + for _, key := range keys { + if value, ok := values[key]; ok { + return value + } + } + return nil +} + func stringSliceFromAny(value any) []string { items, ok := value.([]any) if !ok { diff --git a/cmd/wfctl/plugin_audit_test.go b/cmd/wfctl/plugin_audit_test.go index 7e43688a..230c9fb8 100644 --- a/cmd/wfctl/plugin_audit_test.go +++ b/cmd/wfctl/plugin_audit_test.go @@ -94,6 +94,45 @@ func TestAuditPluginStrictContractsWithGeneratedDescriptors(t *testing.T) { } } +func TestAuditPluginMessageContractsCounted(t *testing.T) { + result := auditPluginRepoWithOptions("testdata/plugins/message-contract", pluginAuditOptions{ + StrictContracts: true, + RequireContractKinds: []string{"message"}, + }) + if len(result.Findings) != 0 { + t.Fatalf("findings = %v", result.Findings) + } + if result.ContractCoverage.Messages.Total != 1 || result.ContractCoverage.Messages.Strict != 1 { + t.Fatalf("message coverage = %+v", result.ContractCoverage.Messages) + } +} + +func TestAuditPluginUnknownContractKindFails(t *testing.T) { + result := auditPluginRepoWithOptions("testdata/plugins/unknown-contract-kind", pluginAuditOptions{StrictContracts: true}) + if !hasPlanFinding(result.Findings, "ERROR", "unknown_contract_kind") { + t.Fatalf("expected unknown contract kind error, got %v", result.Findings) + } +} + +func TestAuditPluginUnknownContractKindWithoutTypeFails(t *testing.T) { + dir := writePluginAuditRepo(t, "workflow-plugin-unknown-kind", `{ + "name": "workflow-plugin-unknown-kind", + "version": "0.1.0", + "capabilities": {} +}`) + writePluginContracts(t, dir, `{ + "version": "1", + "contracts": [ + {"kind": "mystery", "mode": "strict"} + ] +}`) + + result := auditPluginRepoWithOptions(dir, pluginAuditOptions{StrictContracts: true}) + if !hasPlanFinding(result.Findings, "ERROR", "unknown_contract_kind") { + t.Fatalf("expected unknown contract kind error, got %v", result.Findings) + } +} + func TestAuditPluginStrictContractsWithProtoShapedDescriptors(t *testing.T) { dir := writePluginAuditRepo(t, "workflow-plugin-strict-proto-shape", `{ "name": "workflow-plugin-strict-proto-shape", @@ -204,6 +243,23 @@ func TestRunAuditPluginsStrictContractsFailsOnMissingDescriptors(t *testing.T) { } } +func TestRunPluginAuditStrictContractsFailsOnUnknownAndMissingRequiredKinds(t *testing.T) { + var out bytes.Buffer + err := runPluginAuditWithOutput([]string{ + "--strict-contracts", + "--require-contract-kind", "message", + "testdata/plugins/unknown-contract-kind", + }, &out) + if err == nil { + t.Fatalf("expected strict contract audit failure, got nil\n%s", out.String()) + } + for _, want := range []string{"unknown_contract_kind", "missing_required_contract_kind"} { + if !strings.Contains(out.String(), want) { + t.Fatalf("missing %q in output:\n%s", want, out.String()) + } + } +} + func TestRunValidatePluginStrictContractsMissingDescriptors(t *testing.T) { dir := writePluginAuditRepo(t, "workflow-plugin-validate-strict", `{ "name": "workflow-plugin-validate-strict", diff --git a/cmd/wfctl/plugin_validate_contract.go b/cmd/wfctl/plugin_validate_contract.go index 21f4f70e..d54ddbcc 100644 --- a/cmd/wfctl/plugin_validate_contract.go +++ b/cmd/wfctl/plugin_validate_contract.go @@ -26,6 +26,7 @@ func runPluginValidateContract(args []string) error { forPublish := fs.Bool("for-publish", false, "Apply publish-grade checks (strict-semver tag, etc.)") tag := fs.String("tag", "", "Release tag (e.g. v1.2.3); falls back to $GITHUB_REF_NAME then `git describe --tags --exact-match HEAD`") releaseDir := fs.String("release-dir", "", "Post-build verification: assert this dir's plugin.json carries --tag") + requireContractKind := fs.String("require-contract-kind", "", "Require a static contract kind in plugin.contracts.json (for example: message)") fs.Usage = func() { fmt.Fprintf(fs.Output(), `Usage: wfctl plugin validate-contract [options] @@ -69,6 +70,7 @@ Options: var failures []string addFail := func(msg string) { failures = append(failures, msg) } + requiredKinds := parseRequiredContractKinds(*requireContractKind) // Check 1: plugin.json parses + Validate() OK manifestPath := filepath.Join(abs, "plugin.json") @@ -77,6 +79,7 @@ Options: addFail(fmt.Sprintf("plugin.json: %v", err)) } var manifest plugin.PluginManifest + var rawManifest map[string]any if err == nil { if jerr := json.Unmarshal(manifestBytes, &manifest); jerr != nil { addFail(fmt.Sprintf("plugin.json: parse: %v", jerr)) @@ -85,10 +88,26 @@ Options: } else if manifest.Version == "0.0.0" { fmt.Fprintln(os.Stderr, " INFO plugin.json.version is dev sentinel \"0.0.0\" — release builds inject the tag via goreleaser ldflag") } + _ = json.Unmarshal(manifestBytes, &rawManifest) } + descriptors, _, _, _ := loadPluginContractDescriptors(abs, rawManifest, pluginAuditOptions{ + StrictContracts: true, + RequireContractKinds: requiredKinds, + }) + auditResult := auditPluginRepoWithOptions(abs, pluginAuditOptions{ + StrictContracts: true, + RequireContractKinds: requiredKinds, + }) + for _, finding := range auditResult.Findings { + if isValidateContractBlockingFinding(finding) { + addFail(fmt.Sprintf("%s: %s", finding.Code, finding.Message)) + } + } + staticMessageOnly := hasRequiredStaticMessageContract(descriptors, requiredKinds) && !hasPluginRuntimeSurface(abs, rawManifest) + // Check 2 + 3: capabilities + minEngineVersion populated - if err == nil { + if err == nil && !staticMessageOnly { var raw map[string]any if jerr := json.Unmarshal(manifestBytes, &raw); jerr == nil { caps, ok := raw["capabilities"].(map[string]any) @@ -103,15 +122,17 @@ Options: } // Check 4: any cmd/**/main.go contains ResolveBuildVersion AND BuildVersion wiring - mainFound, mainHasContract := scanMainGoFilesForContract(abs) - if !mainFound { - addFail("no cmd/**/main.go (or .go file under repo root) found to scan for contract") - } else if !mainHasContract { - addFail("no main.go contains both sdk.ResolveBuildVersion(...) AND (IaCServeOptions.BuildVersion: ... OR sdk.WithBuildVersion(...))") + if !staticMessageOnly { + mainFound, mainHasContract := scanMainGoFilesForContract(abs) + if !mainFound { + addFail("no cmd/**/main.go (or .go file under repo root) found to scan for contract") + } else if !mainHasContract { + addFail("no main.go contains both sdk.ResolveBuildVersion(...) AND (IaCServeOptions.BuildVersion: ... OR sdk.WithBuildVersion(...))") + } } // Check 5: goreleaser config carries -X *.Version= ldflag - if !goreleaserHasVersionLdflag(abs) { + if !staticMessageOnly && !goreleaserHasVersionLdflag(abs) { addFail(".goreleaser.{yaml,yml}: missing `-X *.Version=` ldflag (mandatory mechanism to deliver release tag into binary)") } @@ -152,6 +173,83 @@ Options: return nil } +func parseRequiredContractKinds(raw string) []string { + var out []string + for _, item := range strings.Split(raw, ",") { + item = strings.TrimSpace(item) + if item != "" { + out = append(out, item) + } + } + return out +} + +func hasRequiredStaticMessageContract(descriptors []pluginContractDescriptor, requiredKinds []string) bool { + var requiresMessage bool + for _, required := range requiredKinds { + if normalizePluginContractKind(required) == "message" { + requiresMessage = true + break + } + } + if !requiresMessage { + return false + } + for _, descriptor := range descriptors { + if normalizePluginContractKind(descriptor.Kind) == "message" && strings.TrimSpace(descriptor.ContractType) != "" { + return true + } + } + return false +} + +func hasPluginRuntimeSurface(dir string, manifest map[string]any) bool { + if strings.TrimSpace(firstStringField(manifest, "minEngineVersion", "min_engine_version")) != "" { + return true + } + switch capabilities := manifest["capabilities"].(type) { + case []any: + if len(capabilities) > 0 { + return true + } + case map[string]any: + if len(capabilities) > 0 { + return true + } + } + if info, err := os.Stat(filepath.Join(dir, "cmd")); err == nil && info.IsDir() { + return true + } + if goreleaserHasVersionLdflag(dir) || + pluginAuditFileExists(filepath.Join(dir, ".goreleaser.yaml")) || + pluginAuditFileExists(filepath.Join(dir, ".goreleaser.yml")) || + pluginAuditFileExists(filepath.Join(dir, "go.mod")) { + return true + } + if entries, err := os.ReadDir(dir); err == nil { + for _, entry := range entries { + if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".go") { + return true + } + } + } + return false +} + +func isValidateContractBlockingFinding(finding planFinding) bool { + switch finding.Code { + case "invalid_plugin_contract_descriptors", + "read_plugin_contract_descriptors", + "unknown_contract_kind", + "unknown_required_contract_kind", + "missing_required_contract_kind", + "invalid_message_contract_descriptor": + return true + default: + return false + } +} + var ( // publishGradeSemverRe aliases the shared PublishGradeSemverRe (workflow#762) // so old in-file references keep working; new code should reference diff --git a/cmd/wfctl/plugin_validate_contract_test.go b/cmd/wfctl/plugin_validate_contract_test.go index e54a3463..73e324dc 100644 --- a/cmd/wfctl/plugin_validate_contract_test.go +++ b/cmd/wfctl/plugin_validate_contract_test.go @@ -1,6 +1,8 @@ package main import ( + "os" + "path/filepath" "strings" "testing" ) @@ -101,3 +103,75 @@ func TestRunPluginValidateContract_MissingArg(t *testing.T) { t.Fatal("expected error for missing plugin-dir arg") } } + +func TestRunPluginValidateContract_MessageContractStaticProfile(t *testing.T) { + err := runPluginValidateContract([]string{ + "--require-contract-kind", "message", + "testdata/plugins/message-contract", + }) + if err != nil { + t.Fatalf("expected descriptor-only message contract to pass, got %v", err) + } +} + +func TestRunPluginValidateContract_MessageContractRuntimeProfile(t *testing.T) { + err := runPluginValidateContract([]string{ + "--require-contract-kind", "message", + "testdata/plugins/message-runtime-contract", + }) + if err != nil { + t.Fatalf("expected runtime-backed message contract to keep release checks and pass, got %v", err) + } +} + +func TestRunPluginValidateContract_MessageContractGoreleaserOnlyRuntimeSurface(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "plugin.json"), []byte(`{ + "name": "message-goreleaser-runtime", + "version": "1.0.0", + "author": "Workflow", + "description": "runtime surface outside cmd/root", + "capabilities": {}, + "contracts": "plugin.contracts.json" +}`), 0644); err != nil { + t.Fatalf("write plugin manifest: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, "plugin.contracts.json"), []byte(`{ + "version": "v1", + "contracts": [ + { + "kind": "message", + "contractType": "compute.network_audit_evidence.v1", + "protoPackage": "workflow_plugin_compute_core.protocol.v1", + "messageNames": ["NetworkAuditRecord"], + "schemaDigest": "sha256:0123456789abcdef", + "protocolVersion": "compute.v1alpha1", + "mode": "strict" + } + ] +}`), 0644); err != nil { + t.Fatalf("write plugin contracts: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, ".goreleaser.yaml"), []byte(`builds: + - main: ./plugin + ldflags: + - -s -w -X main.Version={{.Version}} +`), 0644); err != nil { + t.Fatalf("write goreleaser config: %v", err) + } + + err := runPluginValidateContract([]string{"--require-contract-kind", "message", dir}) + if err == nil { + t.Fatal("expected non-cmd runtime surface to keep executable release checks") + } +} + +func TestRunPluginValidateContract_UnknownContractKindFails(t *testing.T) { + err := runPluginValidateContract([]string{"testdata/plugins/unknown-contract-kind"}) + if err == nil { + t.Fatal("expected unknown contract kind fixture to fail") + } + if !strings.Contains(err.Error(), "contract check") { + t.Fatalf("error = %v, want contract check", err) + } +} diff --git a/cmd/wfctl/testdata/plugins/message-contract/descriptors/message.pb b/cmd/wfctl/testdata/plugins/message-contract/descriptors/message.pb new file mode 100644 index 0000000000000000000000000000000000000000..4591eecc01cd53b92fbe1f350139c6303fd77976 GIT binary patch literal 499 zcmaKpK}*9x5QS&6+mcR;hdmXsAhp=4Bq4YaY$?@);6bDZ4_<<8wlx~FAtddu^x`k^ zqL3=JDqAvXSXGtzqd>mx9<#_ zQUHi4+Q>SR14yGR$r<=Y*^*oEfGxD__Ky@GIR^(3xd2Am-p4iy0hSr`&ai_GV;aUC z)y@P5$F<|+PEHM2&_KYnq%ouIK}lnVCWe!;L565z=+w>x-HX~0OqL3=JDqAvXSXGtzqd>mx9<#_ zQUHi4+Q>SR14yGR$r<=Y*^*oEfGxD__Ky@GIR^(3xd2Am-p4iy0hSr`&ai_GV;aUC z)y@P5$F<|+PEHM2&_KYnq%ouIK}lnVCWe!;L565z=+w>x-HX~0O 0 { + if err := writeFile(filepath.Join(opts.OutputDir, "plugin.contracts.json"), generatePluginContractsJSON(shortName, false, opts.MessageContracts), 0600); err != nil { return err } } @@ -351,22 +361,24 @@ func generateProtoContract(goModule, shortName string) string { return b.String() } -func generatePluginContractsJSON(shortName string) string { +func generatePluginContractsJSON(shortName string, includeStep bool, messageContracts []MessageContract) string { stepType := "step." + shortName + "_example" - return fmt.Sprintf(`{ - "version": "v1", - "contracts": [ - { - "kind": "step", - "type": %q, - "mode": "strict", - "config": "google.protobuf.StringValue", - "input": "google.protobuf.StringValue", - "output": "google.protobuf.StringValue" - } - ] -} -`, stepType) + var contracts []map[string]any + if includeStep { + contracts = append(contracts, map[string]any{ + "kind": "step", + "type": stepType, + "mode": "strict", + "config": "google.protobuf.StringValue", + "input": "google.protobuf.StringValue", + "output": "google.protobuf.StringValue", + }) + } + for _, contract := range messageContracts { + encoded, _ := contract.pluginContractsJSONMap() + contracts = append(contracts, encoded) + } + return encodePluginContractsJSON(contracts) } func generateLegacyProviderGo(opts GenerateOptions, shortName string) string { diff --git a/plugin/sdk/generator_test.go b/plugin/sdk/generator_test.go index 792c23e5..a777fba9 100644 --- a/plugin/sdk/generator_test.go +++ b/plugin/sdk/generator_test.go @@ -1,6 +1,7 @@ package sdk import ( + "encoding/json" "os" "path/filepath" "strings" @@ -56,6 +57,37 @@ func TestTemplateGeneratorGenerate(t *testing.T) { } } +func TestMessageContractDescriptor(t *testing.T) { + descriptor, err := (MessageContract{ + ContractType: "compute.network_audit_evidence.v1", + ProtoPackage: "workflow_plugin_compute_core.protocol.v1", + MessageNames: []string{"NetworkAuditRecord", "NetworkAuditRecordProjection"}, + GoImportPath: "github.com/GoCodeAlone/workflow-plugin-compute-core/protocol/pb", + SchemaDigest: "sha256:0123456789abcdef", + ProtocolVersion: "compute.v1alpha1", + }).ContractDescriptor() + if err != nil { + t.Fatalf("ContractDescriptor: %v", err) + } + if descriptor.GetContractType() != "compute.network_audit_evidence.v1" { + t.Fatalf("contract_type = %q", descriptor.GetContractType()) + } + if got := descriptor.GetMessageNames(); len(got) != 2 || got[1] != "NetworkAuditRecordProjection" { + t.Fatalf("message_names = %v", got) + } +} + +func TestMessageContractDescriptorRequiresReleaseMetadata(t *testing.T) { + _, err := (MessageContract{ + ContractType: "compute.network_audit_evidence.v1", + ProtoPackage: "workflow_plugin_compute_core.protocol.v1", + MessageNames: []string{"NetworkAuditRecord"}, + }).ContractDescriptor() + if err == nil { + t.Fatal("expected missing schema/protocol metadata to fail") + } +} + func TestTemplateGeneratorGenerateStrictContractScaffoldByDefault(t *testing.T) { dir := t.TempDir() outputDir := filepath.Join(dir, "strict-plugin") @@ -153,6 +185,94 @@ func TestTemplateGeneratorGenerateStrictContractScaffoldByDefault(t *testing.T) } } +func TestTemplateGeneratorEmitsMessageContracts(t *testing.T) { + dir := t.TempDir() + outputDir := filepath.Join(dir, "message-plugin") + + gen := NewTemplateGenerator() + err := gen.Generate(GenerateOptions{ + Name: "message-plugin", + Version: "1.0.0", + Author: "TestOrg", + Description: "A message plugin", + OutputDir: outputDir, + WorkflowReplace: filepath.Join(dir, "workflow"), + MessageContracts: []MessageContract{{ + ContractType: "compute.network_audit_evidence.v1", + ProtoPackage: "workflow_plugin_compute_core.protocol.v1", + MessageNames: []string{"NetworkAuditRecord", "NetworkAuditRecordProjection"}, + GoImportPath: "github.com/GoCodeAlone/workflow-plugin-compute-core/protocol/pb", + SchemaDigest: "sha256:0123456789abcdef", + ProtocolVersion: "compute.v1alpha1", + }}, + }) + if err != nil { + t.Fatalf("Generate error: %v", err) + } + + descriptorData, err := os.ReadFile(filepath.Join(outputDir, "plugin.contracts.json")) + if err != nil { + t.Fatalf("read plugin.contracts.json: %v", err) + } + var payload struct { + Contracts []struct { + Kind string `json:"kind"` + ContractType string `json:"contractType"` + ProtoPackage string `json:"protoPackage"` + MessageNames []string `json:"messageNames"` + GoImportPath string `json:"goImportPath"` + SchemaDigest string `json:"schemaDigest"` + ProtocolVersion string `json:"protocolVersion"` + } `json:"contracts"` + } + if err := json.Unmarshal(descriptorData, &payload); err != nil { + t.Fatalf("parse plugin.contracts.json: %v", err) + } + var found bool + for _, contract := range payload.Contracts { + if contract.Kind != "message" { + continue + } + found = true + if contract.ContractType != "compute.network_audit_evidence.v1" { + t.Fatalf("contractType = %q", contract.ContractType) + } + if contract.ProtoPackage != "workflow_plugin_compute_core.protocol.v1" { + t.Fatalf("protoPackage = %q", contract.ProtoPackage) + } + if len(contract.MessageNames) != 2 || contract.MessageNames[1] != "NetworkAuditRecordProjection" { + t.Fatalf("messageNames = %v", contract.MessageNames) + } + if contract.GoImportPath == "" || contract.SchemaDigest == "" || contract.ProtocolVersion == "" { + t.Fatalf("message contract metadata not preserved: %+v", contract) + } + } + if !found { + t.Fatalf("plugin.contracts.json missing message contract:\n%s", descriptorData) + } +} + +func TestTemplateGeneratorRejectsInvalidMessageContracts(t *testing.T) { + dir := t.TempDir() + gen := NewTemplateGenerator() + err := gen.Generate(GenerateOptions{ + Name: "message-plugin", + Version: "1.0.0", + Author: "TestOrg", + Description: "A message plugin", + OutputDir: filepath.Join(dir, "message-plugin"), + WorkflowReplace: filepath.Join(dir, "workflow"), + MessageContracts: []MessageContract{{ + ContractType: "compute.network_audit_evidence.v1", + ProtoPackage: "workflow_plugin_compute_core.protocol.v1", + MessageNames: []string{"NetworkAuditRecord"}, + }}, + }) + if err == nil { + t.Fatal("expected invalid message contract to fail generation") + } +} + func TestTemplateGeneratorGenerateLegacyContractsOptOut(t *testing.T) { dir := t.TempDir() outputDir := filepath.Join(dir, "legacy-plugin") diff --git a/schema/editor_bundle.go b/schema/editor_bundle.go index 04bb1f07..0176f7d5 100644 --- a/schema/editor_bundle.go +++ b/schema/editor_bundle.go @@ -52,16 +52,22 @@ type EditorContractBundle struct { } type EditorContractDescriptor struct { - ID string `json:"id"` - Plugin string `json:"plugin,omitempty"` - OwnerType string `json:"ownerType"` - OwnerKey string `json:"ownerKey"` - Mode string `json:"mode"` - RequestMessage string `json:"requestMessage,omitempty"` - ResponseMessage string `json:"responseMessage,omitempty"` - ConfigMessage string `json:"configMessage,omitempty"` - DescriptorSetRef string `json:"descriptorSetRef,omitempty"` - Source string `json:"source"` + ID string `json:"id"` + Plugin string `json:"plugin,omitempty"` + OwnerType string `json:"ownerType"` + OwnerKey string `json:"ownerKey"` + Mode string `json:"mode"` + RequestMessage string `json:"requestMessage,omitempty"` + ResponseMessage string `json:"responseMessage,omitempty"` + ConfigMessage string `json:"configMessage,omitempty"` + DescriptorSetRef string `json:"descriptorSetRef,omitempty"` + Source string `json:"source"` + ContractType string `json:"contractType,omitempty"` + ProtoPackage string `json:"protoPackage,omitempty"` + MessageNames []string `json:"messageNames,omitempty"` + GoImportPath string `json:"goImportPath,omitempty"` + SchemaDigest string `json:"schemaDigest,omitempty"` + ProtocolVersion string `json:"protocolVersion,omitempty"` } type EditorMessageDescriptor struct { @@ -374,6 +380,12 @@ func normalizeContractDescriptor(source EditorContractRegistrySource, descriptor ConfigMessage: descriptor.ConfigMessage, DescriptorSetRef: descriptorSetRef, Source: editorContractSource(source.Source), + ContractType: descriptor.ContractType, + ProtoPackage: descriptor.ProtoPackage, + MessageNames: append([]string(nil), descriptor.MessageNames...), + GoImportPath: descriptor.GoImportPath, + SchemaDigest: descriptor.SchemaDigest, + ProtocolVersion: descriptor.ProtocolVersion, } } @@ -399,6 +411,8 @@ func editorContractOwner(descriptor *pb.ContractDescriptor) (string, string) { return "service", descriptor.ServiceName + "/" + descriptor.Method } return "service", descriptor.Method + case pb.ContractKind_CONTRACT_KIND_MESSAGE: + return "message", descriptor.ContractType default: return "", "" } @@ -437,7 +451,11 @@ func editorContractSource(source string) string { } func addReferencedMessagePlaceholders(messages map[string]*EditorMessageDescriptor, contract *EditorContractDescriptor, descriptorSetRef string) { - for _, name := range []string{contract.ConfigMessage, contract.RequestMessage, contract.ResponseMessage} { + names := []string{contract.ConfigMessage, contract.RequestMessage, contract.ResponseMessage} + for _, messageName := range contract.MessageNames { + names = append(names, editorBundleMessageFullName(contract.ProtoPackage, messageName)) + } + for _, name := range names { if name == "" { continue } @@ -459,6 +477,14 @@ func addReferencedMessagePlaceholders(messages map[string]*EditorMessageDescript } } +func editorBundleMessageFullName(protoPackage, name string) string { + name = strings.TrimSpace(name) + if name == "" || strings.Contains(name, ".") || strings.TrimSpace(protoPackage) == "" { + return name + } + return strings.TrimSpace(protoPackage) + "." + name +} + func GenerateInfraSchema() *Schema { s := &Schema{ Schema: "https://json-schema.org/draft/2020-12/schema", diff --git a/schema/editor_bundle_test.go b/schema/editor_bundle_test.go index b990fdcc..6c02ef05 100644 --- a/schema/editor_bundle_test.go +++ b/schema/editor_bundle_test.go @@ -168,6 +168,53 @@ func TestExportEditorBundleIncludesExternalDescriptorSetReferences(t *testing.T) } } +func TestExportEditorBundlePreservesMessageContracts(t *testing.T) { + bundle, err := ExportEditorBundle(EditorBundleOptions{ + ContractRegistries: []EditorContractRegistrySource{ + { + Plugin: "workflow-plugin-compute-core", + Source: EditorContractSourcePluginContractsJSON, + DescriptorSetRef: "descriptors/message.pb", + Registry: &pb.ContractRegistry{ + Contracts: []*pb.ContractDescriptor{ + { + Kind: pb.ContractKind_CONTRACT_KIND_MESSAGE, + Mode: pb.ContractMode_CONTRACT_MODE_STRICT_PROTO, + ContractType: "compute.network_audit_evidence.v1", + ProtoPackage: "workflow_plugin_compute_core.protocol.v1", + MessageNames: []string{"NetworkAuditRecord", "NetworkAuditRecordProjection"}, + GoImportPath: "github.com/GoCodeAlone/workflow-plugin-compute-core/protocol/pb", + SchemaDigest: "sha256:0123456789abcdef", + ProtocolVersion: "compute.v1alpha1", + }, + }, + }, + }, + }, + }) + if err != nil { + t.Fatalf("export editor bundle: %v", err) + } + + contract := bundle.Contracts["message:compute.network_audit_evidence.v1"] + if contract == nil { + t.Fatalf("expected message contract, got keys %+v", bundle.Contracts) + } + if contract.ProtoPackage != "workflow_plugin_compute_core.protocol.v1" { + t.Fatalf("proto package = %q", contract.ProtoPackage) + } + if len(contract.MessageNames) != 2 || contract.MessageNames[1] != "NetworkAuditRecordProjection" { + t.Fatalf("message names = %v", contract.MessageNames) + } + if contract.GoImportPath == "" || contract.SchemaDigest == "" || contract.ProtocolVersion == "" { + t.Fatalf("message metadata not preserved: %+v", contract) + } + fullName := "workflow_plugin_compute_core.protocol.v1.NetworkAuditRecordProjection" + if bundle.Messages[fullName] == nil { + t.Fatalf("expected message placeholder %q, got %+v", fullName, bundle.Messages) + } +} + func TestExportEditorBundlePreservesPerContractExternalDescriptorSetReferences(t *testing.T) { bundle, err := ExportEditorBundle(EditorBundleOptions{ ContractRegistries: []EditorContractRegistrySource{ diff --git a/scripts/resolve-gh-run-for-ref.sh b/scripts/resolve-gh-run-for-ref.sh new file mode 100755 index 00000000..f0abd477 --- /dev/null +++ b/scripts/resolve-gh-run-for-ref.sh @@ -0,0 +1,104 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat >&2 <<'USAGE' +Usage: resolve-gh-run-for-ref.sh --workflow --commit --event --branch --created-after [--repo ] + +Find exactly one GitHub Actions run matching the requested workflow, head SHA, +event, branch, and created-at lower bound. Prints the run database ID. +USAGE +} + +workflow="" +commit="" +event="" +branch="" +created_after="" +repo="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --workflow) + workflow="${2:-}" + shift 2 + ;; + --commit) + commit="${2:-}" + shift 2 + ;; + --event) + event="${2:-}" + shift 2 + ;; + --branch) + branch="${2:-}" + shift 2 + ;; + --created-after) + created_after="${2:-}" + shift 2 + ;; + --repo) + repo="${2:-}" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "unknown argument: $1" >&2 + usage + exit 2 + ;; + esac +done + +for required in workflow commit event branch created_after; do + if [[ -z "${!required}" ]]; then + echo "missing required argument: --${required//_/-}" >&2 + usage + exit 2 + fi +done + +if ! command -v gh >/dev/null 2>&1; then + echo "gh CLI is required" >&2 + exit 127 +fi +if ! command -v jq >/dev/null 2>&1; then + echo "jq is required" >&2 + exit 127 +fi + +gh_args=(run list --workflow "$workflow" --limit 100 --json databaseId,headSha,event,headBranch,createdAt,url) +if [[ -n "$repo" ]]; then + gh_args+=(--repo "$repo") +fi + +matches="$( + gh "${gh_args[@]}" | + jq -r \ + --arg commit "$commit" \ + --arg event "$event" \ + --arg branch "$branch" \ + --arg created_after "$created_after" \ + '.[] | select( + .headSha == $commit and + .event == $event and + .headBranch == $branch and + .createdAt >= $created_after + ) | "\(.databaseId)\t\(.createdAt)\t\(.url)"' +)" + +count="$(printf '%s\n' "$matches" | sed '/^$/d' | wc -l | tr -d ' ')" +if [[ "$count" != "1" ]]; then + echo "expected exactly one matching workflow run, found ${count}" >&2 + if [[ -n "$matches" ]]; then + printf '%s\n' "$matches" >&2 + fi + exit 1 +fi + +printf '%s\n' "$matches" | cut -f1 From f6beaf941f900fb74ad6942542c65d32484af3fa Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Wed, 27 May 2026 02:12:50 -0400 Subject: [PATCH 2/2] fix: avoid message contract range copy --- cmd/wfctl/plugin_validate_contract.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cmd/wfctl/plugin_validate_contract.go b/cmd/wfctl/plugin_validate_contract.go index d54ddbcc..65090c31 100644 --- a/cmd/wfctl/plugin_validate_contract.go +++ b/cmd/wfctl/plugin_validate_contract.go @@ -195,7 +195,8 @@ func hasRequiredStaticMessageContract(descriptors []pluginContractDescriptor, re if !requiresMessage { return false } - for _, descriptor := range descriptors { + for i := range descriptors { + descriptor := &descriptors[i] if normalizePluginContractKind(descriptor.Kind) == "message" && strings.TrimSpace(descriptor.ContractType) != "" { return true }