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..65090c31 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,84 @@ 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 i := range descriptors { + descriptor := &descriptors[i] + 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 00000000..4591eecc Binary files /dev/null and b/cmd/wfctl/testdata/plugins/message-contract/descriptors/message.pb differ diff --git a/cmd/wfctl/testdata/plugins/message-contract/plugin.contracts.json b/cmd/wfctl/testdata/plugins/message-contract/plugin.contracts.json new file mode 100644 index 00000000..7229dbb8 --- /dev/null +++ b/cmd/wfctl/testdata/plugins/message-contract/plugin.contracts.json @@ -0,0 +1,19 @@ +{ + "version": "v1", + "descriptorSetRef": "descriptors/message.pb", + "contracts": [ + { + "kind": "message", + "contractType": "compute.network_audit_evidence.v1", + "protoPackage": "workflow_plugin_compute_core.protocol.v1", + "messageNames": [ + "NetworkAuditRecord", + "NetworkAuditRecordProjection" + ], + "goImportPath": "github.com/GoCodeAlone/workflow-plugin-compute-core/protocol/pb", + "schemaDigest": "sha256:0123456789abcdef", + "protocolVersion": "compute.v1alpha1", + "mode": "strict" + } + ] +} diff --git a/cmd/wfctl/testdata/plugins/message-contract/plugin.json b/cmd/wfctl/testdata/plugins/message-contract/plugin.json new file mode 100644 index 00000000..fc367a5c --- /dev/null +++ b/cmd/wfctl/testdata/plugins/message-contract/plugin.json @@ -0,0 +1,7 @@ +{ + "name": "message-contract", + "version": "1.0.0", + "author": "Workflow", + "description": "Descriptor-only message contract fixture", + "capabilities": {} +} diff --git a/cmd/wfctl/testdata/plugins/message-runtime-contract/.goreleaser.yaml b/cmd/wfctl/testdata/plugins/message-runtime-contract/.goreleaser.yaml new file mode 100644 index 00000000..74c1355a --- /dev/null +++ b/cmd/wfctl/testdata/plugins/message-runtime-contract/.goreleaser.yaml @@ -0,0 +1,5 @@ +builds: + - id: message-runtime-contract + main: ./cmd/plugin + ldflags: + - -s -w -X main.Version={{.Version}} diff --git a/cmd/wfctl/testdata/plugins/message-runtime-contract/cmd/plugin/main.go b/cmd/wfctl/testdata/plugins/message-runtime-contract/cmd/plugin/main.go new file mode 100644 index 00000000..146200ad --- /dev/null +++ b/cmd/wfctl/testdata/plugins/message-runtime-contract/cmd/plugin/main.go @@ -0,0 +1,30 @@ +package main + +import ( + "context" + + pb "github.com/GoCodeAlone/workflow/plugin/external/proto" + "github.com/GoCodeAlone/workflow/plugin/external/sdk" + "google.golang.org/protobuf/types/known/emptypb" +) + +var Version = "0.0.0" + +type provider struct { + pb.UnimplementedPluginServiceServer +} + +func main() { + _ = sdk.WithBuildVersion(sdk.ResolveBuildVersion(Version)) +} + +func (provider) GetContractRegistry(context.Context, *emptypb.Empty) (*pb.ContractRegistry, error) { + return sdk.BuildMessageContractRegistry(sdk.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", + }) +} diff --git a/cmd/wfctl/testdata/plugins/message-runtime-contract/descriptors/message.pb b/cmd/wfctl/testdata/plugins/message-runtime-contract/descriptors/message.pb new file mode 100644 index 00000000..4591eecc Binary files /dev/null and b/cmd/wfctl/testdata/plugins/message-runtime-contract/descriptors/message.pb differ diff --git a/cmd/wfctl/testdata/plugins/message-runtime-contract/plugin.contracts.json b/cmd/wfctl/testdata/plugins/message-runtime-contract/plugin.contracts.json new file mode 100644 index 00000000..7229dbb8 --- /dev/null +++ b/cmd/wfctl/testdata/plugins/message-runtime-contract/plugin.contracts.json @@ -0,0 +1,19 @@ +{ + "version": "v1", + "descriptorSetRef": "descriptors/message.pb", + "contracts": [ + { + "kind": "message", + "contractType": "compute.network_audit_evidence.v1", + "protoPackage": "workflow_plugin_compute_core.protocol.v1", + "messageNames": [ + "NetworkAuditRecord", + "NetworkAuditRecordProjection" + ], + "goImportPath": "github.com/GoCodeAlone/workflow-plugin-compute-core/protocol/pb", + "schemaDigest": "sha256:0123456789abcdef", + "protocolVersion": "compute.v1alpha1", + "mode": "strict" + } + ] +} diff --git a/cmd/wfctl/testdata/plugins/message-runtime-contract/plugin.json b/cmd/wfctl/testdata/plugins/message-runtime-contract/plugin.json new file mode 100644 index 00000000..5ce65b9b --- /dev/null +++ b/cmd/wfctl/testdata/plugins/message-runtime-contract/plugin.json @@ -0,0 +1,12 @@ +{ + "name": "message-runtime-contract", + "version": "1.0.0", + "author": "Workflow", + "description": "Runtime-backed message contract fixture", + "minEngineVersion": "0.1.0", + "capabilities": { + "moduleTypes": [ + "message.contracts" + ] + } +} diff --git a/cmd/wfctl/testdata/plugins/unknown-contract-kind/plugin.contracts.json b/cmd/wfctl/testdata/plugins/unknown-contract-kind/plugin.contracts.json new file mode 100644 index 00000000..c65be689 --- /dev/null +++ b/cmd/wfctl/testdata/plugins/unknown-contract-kind/plugin.contracts.json @@ -0,0 +1,10 @@ +{ + "version": "v1", + "contracts": [ + { + "kind": "mystery", + "type": "mystery.contract", + "mode": "strict" + } + ] +} diff --git a/cmd/wfctl/testdata/plugins/unknown-contract-kind/plugin.json b/cmd/wfctl/testdata/plugins/unknown-contract-kind/plugin.json new file mode 100644 index 00000000..5fd8b451 --- /dev/null +++ b/cmd/wfctl/testdata/plugins/unknown-contract-kind/plugin.json @@ -0,0 +1,6 @@ +{ + "name": "unknown-contract-kind", + "version": "1.0.0", + "author": "Workflow", + "description": "Unknown contract kind fixture" +} diff --git a/plugin/external/message_contract_test.go b/plugin/external/message_contract_test.go new file mode 100644 index 00000000..4d18d37a --- /dev/null +++ b/plugin/external/message_contract_test.go @@ -0,0 +1,50 @@ +package external + +import ( + "testing" + + pb "github.com/GoCodeAlone/workflow/plugin/external/proto" + goproto "google.golang.org/protobuf/proto" +) + +func TestContractDescriptorPreservesMessageContractFields(t *testing.T) { + descriptor := &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", + } + data, err := goproto.Marshal(descriptor) + if err != nil { + t.Fatalf("marshal descriptor: %v", err) + } + var roundTrip pb.ContractDescriptor + if err := goproto.Unmarshal(data, &roundTrip); err != nil { + t.Fatalf("unmarshal descriptor: %v", err) + } + if roundTrip.GetKind() != pb.ContractKind_CONTRACT_KIND_MESSAGE { + t.Fatalf("kind = %v, want MESSAGE", roundTrip.GetKind()) + } + if roundTrip.GetContractType() != descriptor.ContractType { + t.Fatalf("contract_type = %q, want %q", roundTrip.GetContractType(), descriptor.ContractType) + } + if roundTrip.GetProtoPackage() != descriptor.ProtoPackage { + t.Fatalf("proto_package = %q, want %q", roundTrip.GetProtoPackage(), descriptor.ProtoPackage) + } + if got, want := roundTrip.GetMessageNames(), descriptor.MessageNames; len(got) != len(want) || got[1] != want[1] { + t.Fatalf("message_names = %v, want %v", got, want) + } + if roundTrip.GetGoImportPath() != descriptor.GoImportPath { + t.Fatalf("go_import_path = %q, want %q", roundTrip.GetGoImportPath(), descriptor.GoImportPath) + } + if roundTrip.GetSchemaDigest() != descriptor.SchemaDigest { + t.Fatalf("schema_digest = %q, want %q", roundTrip.GetSchemaDigest(), descriptor.SchemaDigest) + } + if roundTrip.GetProtocolVersion() != descriptor.ProtocolVersion { + t.Fatalf("protocol_version = %q, want %q", roundTrip.GetProtocolVersion(), descriptor.ProtocolVersion) + } +} diff --git a/plugin/external/proto/plugin.pb.go b/plugin/external/proto/plugin.pb.go index dc553983..69dd7e90 100644 --- a/plugin/external/proto/plugin.pb.go +++ b/plugin/external/proto/plugin.pb.go @@ -34,6 +34,7 @@ const ( ContractKind_CONTRACT_KIND_STEP ContractKind = 2 ContractKind_CONTRACT_KIND_SERVICE ContractKind = 3 ContractKind_CONTRACT_KIND_TRIGGER ContractKind = 4 + ContractKind_CONTRACT_KIND_MESSAGE ContractKind = 5 ) // Enum value maps for ContractKind. @@ -44,6 +45,7 @@ var ( 2: "CONTRACT_KIND_STEP", 3: "CONTRACT_KIND_SERVICE", 4: "CONTRACT_KIND_TRIGGER", + 5: "CONTRACT_KIND_MESSAGE", } ContractKind_value = map[string]int32{ "CONTRACT_KIND_UNSPECIFIED": 0, @@ -51,6 +53,7 @@ var ( "CONTRACT_KIND_STEP": 2, "CONTRACT_KIND_SERVICE": 3, "CONTRACT_KIND_TRIGGER": 4, + "CONTRACT_KIND_MESSAGE": 5, } ) @@ -276,19 +279,25 @@ func (x *ContractRegistry) GetFileDescriptorSet() *descriptorpb.FileDescriptorSe // ContractDescriptor maps a plugin capability type to protobuf message names. type ContractDescriptor struct { - state protoimpl.MessageState `protogen:"open.v1"` - Kind ContractKind `protobuf:"varint,1,opt,name=kind,proto3,enum=workflow.plugin.v1.ContractKind" json:"kind,omitempty"` - ModuleType string `protobuf:"bytes,2,opt,name=module_type,json=moduleType,proto3" json:"module_type,omitempty"` - StepType string `protobuf:"bytes,3,opt,name=step_type,json=stepType,proto3" json:"step_type,omitempty"` - TriggerType string `protobuf:"bytes,4,opt,name=trigger_type,json=triggerType,proto3" json:"trigger_type,omitempty"` - ServiceName string `protobuf:"bytes,5,opt,name=service_name,json=serviceName,proto3" json:"service_name,omitempty"` - Method string `protobuf:"bytes,6,opt,name=method,proto3" json:"method,omitempty"` - ConfigMessage string `protobuf:"bytes,7,opt,name=config_message,json=configMessage,proto3" json:"config_message,omitempty"` - InputMessage string `protobuf:"bytes,8,opt,name=input_message,json=inputMessage,proto3" json:"input_message,omitempty"` - OutputMessage string `protobuf:"bytes,9,opt,name=output_message,json=outputMessage,proto3" json:"output_message,omitempty"` - Mode ContractMode `protobuf:"varint,10,opt,name=mode,proto3,enum=workflow.plugin.v1.ContractMode" json:"mode,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + Kind ContractKind `protobuf:"varint,1,opt,name=kind,proto3,enum=workflow.plugin.v1.ContractKind" json:"kind,omitempty"` + ModuleType string `protobuf:"bytes,2,opt,name=module_type,json=moduleType,proto3" json:"module_type,omitempty"` + StepType string `protobuf:"bytes,3,opt,name=step_type,json=stepType,proto3" json:"step_type,omitempty"` + TriggerType string `protobuf:"bytes,4,opt,name=trigger_type,json=triggerType,proto3" json:"trigger_type,omitempty"` + ServiceName string `protobuf:"bytes,5,opt,name=service_name,json=serviceName,proto3" json:"service_name,omitempty"` + Method string `protobuf:"bytes,6,opt,name=method,proto3" json:"method,omitempty"` + ConfigMessage string `protobuf:"bytes,7,opt,name=config_message,json=configMessage,proto3" json:"config_message,omitempty"` + InputMessage string `protobuf:"bytes,8,opt,name=input_message,json=inputMessage,proto3" json:"input_message,omitempty"` + OutputMessage string `protobuf:"bytes,9,opt,name=output_message,json=outputMessage,proto3" json:"output_message,omitempty"` + Mode ContractMode `protobuf:"varint,10,opt,name=mode,proto3,enum=workflow.plugin.v1.ContractMode" json:"mode,omitempty"` + ContractType string `protobuf:"bytes,11,opt,name=contract_type,json=contractType,proto3" json:"contract_type,omitempty"` + ProtoPackage string `protobuf:"bytes,12,opt,name=proto_package,json=protoPackage,proto3" json:"proto_package,omitempty"` + MessageNames []string `protobuf:"bytes,13,rep,name=message_names,json=messageNames,proto3" json:"message_names,omitempty"` + GoImportPath string `protobuf:"bytes,14,opt,name=go_import_path,json=goImportPath,proto3" json:"go_import_path,omitempty"` + SchemaDigest string `protobuf:"bytes,15,opt,name=schema_digest,json=schemaDigest,proto3" json:"schema_digest,omitempty"` + ProtocolVersion string `protobuf:"bytes,16,opt,name=protocol_version,json=protocolVersion,proto3" json:"protocol_version,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *ContractDescriptor) Reset() { @@ -391,6 +400,48 @@ func (x *ContractDescriptor) GetMode() ContractMode { return ContractMode_CONTRACT_MODE_UNSPECIFIED } +func (x *ContractDescriptor) GetContractType() string { + if x != nil { + return x.ContractType + } + return "" +} + +func (x *ContractDescriptor) GetProtoPackage() string { + if x != nil { + return x.ProtoPackage + } + return "" +} + +func (x *ContractDescriptor) GetMessageNames() []string { + if x != nil { + return x.MessageNames + } + return nil +} + +func (x *ContractDescriptor) GetGoImportPath() string { + if x != nil { + return x.GoImportPath + } + return "" +} + +func (x *ContractDescriptor) GetSchemaDigest() string { + if x != nil { + return x.SchemaDigest + } + return "" +} + +func (x *ContractDescriptor) GetProtocolVersion() string { + if x != nil { + return x.ProtocolVersion + } + return "" +} + // GetAssetRequest asks the plugin to return an embedded static asset. type GetAssetRequest struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -2169,7 +2220,7 @@ const file_plugin_proto_rawDesc = "" + "\x0fsample_category\x18\x06 \x01(\tR\x0esampleCategory\"\xac\x01\n" + "\x10ContractRegistry\x12D\n" + "\tcontracts\x18\x01 \x03(\v2&.workflow.plugin.v1.ContractDescriptorR\tcontracts\x12R\n" + - "\x13file_descriptor_set\x18\x02 \x01(\v2\".google.protobuf.FileDescriptorSetR\x11fileDescriptorSet\"\x8f\x03\n" + + "\x13file_descriptor_set\x18\x02 \x01(\v2\".google.protobuf.FileDescriptorSetR\x11fileDescriptorSet\"\xf4\x04\n" + "\x12ContractDescriptor\x124\n" + "\x04kind\x18\x01 \x01(\x0e2 .workflow.plugin.v1.ContractKindR\x04kind\x12\x1f\n" + "\vmodule_type\x18\x02 \x01(\tR\n" + @@ -2182,7 +2233,13 @@ const file_plugin_proto_rawDesc = "" + "\rinput_message\x18\b \x01(\tR\finputMessage\x12%\n" + "\x0eoutput_message\x18\t \x01(\tR\routputMessage\x124\n" + "\x04mode\x18\n" + - " \x01(\x0e2 .workflow.plugin.v1.ContractModeR\x04mode\"%\n" + + " \x01(\x0e2 .workflow.plugin.v1.ContractModeR\x04mode\x12#\n" + + "\rcontract_type\x18\v \x01(\tR\fcontractType\x12#\n" + + "\rproto_package\x18\f \x01(\tR\fprotoPackage\x12#\n" + + "\rmessage_names\x18\r \x03(\tR\fmessageNames\x12$\n" + + "\x0ego_import_path\x18\x0e \x01(\tR\fgoImportPath\x12#\n" + + "\rschema_digest\x18\x0f \x01(\tR\fschemaDigest\x12)\n" + + "\x10protocol_version\x18\x10 \x01(\tR\x0fprotocolVersion\"%\n" + "\x0fGetAssetRequest\x12\x12\n" + "\x04path\x18\x01 \x01(\tR\x04path\"e\n" + "\x10GetAssetResponse\x12\x18\n" + @@ -2315,13 +2372,14 @@ const file_plugin_proto_rawDesc = "" + "\vyaml_config\x18\x01 \x01(\fR\n" + "yamlConfig\x12\x1d\n" + "\n" + - "plugin_dir\x18\x02 \x01(\tR\tpluginDir*\x95\x01\n" + + "plugin_dir\x18\x02 \x01(\tR\tpluginDir*\xb0\x01\n" + "\fContractKind\x12\x1d\n" + "\x19CONTRACT_KIND_UNSPECIFIED\x10\x00\x12\x18\n" + "\x14CONTRACT_KIND_MODULE\x10\x01\x12\x16\n" + "\x12CONTRACT_KIND_STEP\x10\x02\x12\x19\n" + "\x15CONTRACT_KIND_SERVICE\x10\x03\x12\x19\n" + - "\x15CONTRACT_KIND_TRIGGER\x10\x04*\x9a\x01\n" + + "\x15CONTRACT_KIND_TRIGGER\x10\x04\x12\x19\n" + + "\x15CONTRACT_KIND_MESSAGE\x10\x05*\x9a\x01\n" + "\fContractMode\x12\x1d\n" + "\x19CONTRACT_MODE_UNSPECIFIED\x10\x00\x12\x1f\n" + "\x1bCONTRACT_MODE_LEGACY_STRUCT\x10\x01\x12*\n" + diff --git a/plugin/external/proto/plugin.proto b/plugin/external/proto/plugin.proto index 9361101f..3833a450 100644 --- a/plugin/external/proto/plugin.proto +++ b/plugin/external/proto/plugin.proto @@ -110,6 +110,7 @@ enum ContractKind { CONTRACT_KIND_STEP = 2; CONTRACT_KIND_SERVICE = 3; CONTRACT_KIND_TRIGGER = 4; + CONTRACT_KIND_MESSAGE = 5; } // ContractMode describes how strictly the host and plugin should use typed payloads. @@ -140,6 +141,12 @@ message ContractDescriptor { string input_message = 8; string output_message = 9; ContractMode mode = 10; + string contract_type = 11; + string proto_package = 12; + repeated string message_names = 13; + string go_import_path = 14; + string schema_digest = 15; + string protocol_version = 16; } // GetAssetRequest asks the plugin to return an embedded static asset. diff --git a/plugin/external/sdk/contracts.go b/plugin/external/sdk/contracts.go index dc8e5820..52308fbe 100644 --- a/plugin/external/sdk/contracts.go +++ b/plugin/external/sdk/contracts.go @@ -1,6 +1,7 @@ package sdk import ( + "fmt" "sort" "strings" @@ -9,6 +10,68 @@ import ( pb "github.com/GoCodeAlone/workflow/plugin/external/proto" ) +// MessageContract describes a descriptor-only protobuf message contract. +type MessageContract struct { + ContractType string + ProtoPackage string + MessageNames []string + GoImportPath string + SchemaDigest string + ProtocolVersion string +} + +// BuildMessageContractRegistry returns a registry containing MESSAGE-kind +// descriptors. Descriptor-only plugins can expose these contracts statically +// in plugin.contracts.json; runtime-backed plugins can return the same shape +// from ContractRegistry for parity tests. +func BuildMessageContractRegistry(contracts ...MessageContract) (*pb.ContractRegistry, error) { + registry := &pb.ContractRegistry{} + for _, contract := range contracts { + descriptor, err := contractDescriptorForMessageContract(contract) + if err != nil { + return nil, err + } + registry.Contracts = append(registry.Contracts, descriptor) + } + return registry, nil +} + +func contractDescriptorForMessageContract(contract MessageContract) (*pb.ContractDescriptor, error) { + if strings.TrimSpace(contract.ContractType) == "" { + return nil, fmt.Errorf("message contract type is required") + } + if strings.TrimSpace(contract.ProtoPackage) == "" { + return nil, fmt.Errorf("message contract proto package is required") + } + if len(contract.MessageNames) == 0 { + return nil, fmt.Errorf("message contract must declare at least one message") + } + if strings.TrimSpace(contract.SchemaDigest) == "" { + return nil, fmt.Errorf("message contract schema digest is required") + } + if strings.TrimSpace(contract.ProtocolVersion) == "" { + return nil, fmt.Errorf("message contract protocol version is required") + } + names := make([]string, 0, len(contract.MessageNames)) + for _, name := range contract.MessageNames { + name = strings.TrimSpace(name) + if name == "" { + return nil, fmt.Errorf("message contract contains empty message name") + } + names = append(names, name) + } + return &pb.ContractDescriptor{ + Kind: pb.ContractKind_CONTRACT_KIND_MESSAGE, + Mode: pb.ContractMode_CONTRACT_MODE_STRICT_PROTO, + ContractType: contract.ContractType, + ProtoPackage: contract.ProtoPackage, + MessageNames: names, + GoImportPath: contract.GoImportPath, + SchemaDigest: contract.SchemaDigest, + ProtocolVersion: contract.ProtocolVersion, + }, nil +} + // BuildContractRegistry enumerates the gRPC services registered on // grpcSrv and returns a *pb.ContractRegistry with a SERVICE-kind // ContractDescriptor for each one. Mode is set to diff --git a/plugin/external/sdk/contracts_test.go b/plugin/external/sdk/contracts_test.go index 3ee293ea..4ddddd05 100644 --- a/plugin/external/sdk/contracts_test.go +++ b/plugin/external/sdk/contracts_test.go @@ -8,6 +8,44 @@ import ( "google.golang.org/grpc" ) +func TestBuildMessageContractRegistry(t *testing.T) { + reg, err := BuildMessageContractRegistry(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("BuildMessageContractRegistry: %v", err) + } + if len(reg.Contracts) != 1 { + t.Fatalf("contracts = %d, want 1", len(reg.Contracts)) + } + contract := reg.Contracts[0] + if contract.Kind != pb.ContractKind_CONTRACT_KIND_MESSAGE { + t.Fatalf("kind = %v, want MESSAGE", contract.Kind) + } + if contract.ContractType != "compute.network_audit_evidence.v1" { + t.Fatalf("contract type = %q", contract.ContractType) + } + if got := contract.MessageNames; len(got) != 2 || got[0] != "NetworkAuditRecord" { + t.Fatalf("message names = %v", got) + } +} + +func TestBuildMessageContractRegistryRequiresReleaseMetadata(t *testing.T) { + _, err := BuildMessageContractRegistry(MessageContract{ + ContractType: "compute.network_audit_evidence.v1", + ProtoPackage: "workflow_plugin_compute_core.protocol.v1", + MessageNames: []string{"NetworkAuditRecord"}, + }) + if err == nil { + t.Fatal("expected missing schema/protocol metadata to fail") + } +} + func TestBuildContractRegistryForPlugin_NilServer(t *testing.T) { reg := BuildContractRegistryForPlugin(nil, "workflow.plugin.external.iac.") if reg == nil { diff --git a/plugin/sdk/contracts.go b/plugin/sdk/contracts.go new file mode 100644 index 00000000..6d7b4967 --- /dev/null +++ b/plugin/sdk/contracts.go @@ -0,0 +1,82 @@ +package sdk + +import ( + "encoding/json" + "fmt" + "strings" + + pb "github.com/GoCodeAlone/workflow/plugin/external/proto" +) + +// MessageContract describes a descriptor-only protobuf message contract. +type MessageContract struct { + ContractType string `json:"contractType"` + ProtoPackage string `json:"protoPackage"` + MessageNames []string `json:"messageNames"` + GoImportPath string `json:"goImportPath,omitempty"` + SchemaDigest string `json:"schemaDigest,omitempty"` + ProtocolVersion string `json:"protocolVersion,omitempty"` +} + +// ContractDescriptor converts c into the shared plugin contract descriptor. +func (c MessageContract) ContractDescriptor() (*pb.ContractDescriptor, error) { + if strings.TrimSpace(c.ContractType) == "" { + return nil, fmt.Errorf("message contract type is required") + } + if strings.TrimSpace(c.ProtoPackage) == "" { + return nil, fmt.Errorf("message contract proto package is required") + } + if len(c.MessageNames) == 0 { + return nil, fmt.Errorf("message contract must declare at least one message") + } + if strings.TrimSpace(c.SchemaDigest) == "" { + return nil, fmt.Errorf("message contract schema digest is required") + } + if strings.TrimSpace(c.ProtocolVersion) == "" { + return nil, fmt.Errorf("message contract protocol version is required") + } + names := make([]string, 0, len(c.MessageNames)) + for _, name := range c.MessageNames { + name = strings.TrimSpace(name) + if name == "" { + return nil, fmt.Errorf("message contract contains empty message name") + } + names = append(names, name) + } + return &pb.ContractDescriptor{ + Kind: pb.ContractKind_CONTRACT_KIND_MESSAGE, + ContractType: c.ContractType, + ProtoPackage: c.ProtoPackage, + MessageNames: names, + GoImportPath: c.GoImportPath, + SchemaDigest: c.SchemaDigest, + ProtocolVersion: c.ProtocolVersion, + Mode: pb.ContractMode_CONTRACT_MODE_STRICT_PROTO, + }, nil +} + +func (c MessageContract) pluginContractsJSONMap() (map[string]any, error) { + if _, err := c.ContractDescriptor(); err != nil { + return nil, err + } + out := map[string]any{ + "kind": "message", + "contractType": c.ContractType, + "protoPackage": c.ProtoPackage, + "messageNames": append([]string(nil), c.MessageNames...), + "mode": "strict", + "goImportPath": c.GoImportPath, + "schemaDigest": c.SchemaDigest, + "protocolVersion": c.ProtocolVersion, + } + return out, nil +} + +func encodePluginContractsJSON(contracts []map[string]any) string { + payload := map[string]any{ + "version": "v1", + "contracts": contracts, + } + data, _ := json.MarshalIndent(payload, "", " ") + return string(data) + "\n" +} diff --git a/plugin/sdk/generator.go b/plugin/sdk/generator.go index d3a01f60..aad80efd 100644 --- a/plugin/sdk/generator.go +++ b/plugin/sdk/generator.go @@ -27,16 +27,17 @@ func NewTemplateGenerator() *TemplateGenerator { // GenerateOptions configures what gets generated. type GenerateOptions struct { - Name string - Version string - Author string - Description string - License string - OutputDir string - WithContract bool - LegacyContracts bool - GoModule string // e.g. "github.com/MyOrg/workflow-plugin-foo" - WorkflowReplace string // optional local replace path for github.com/GoCodeAlone/workflow + Name string + Version string + Author string + Description string + License string + OutputDir string + WithContract bool + LegacyContracts bool + GoModule string // e.g. "github.com/MyOrg/workflow-plugin-foo" + WorkflowReplace string // optional local replace path for github.com/GoCodeAlone/workflow + MessageContracts []MessageContract } // Generate creates a new plugin directory with manifest and component skeleton, @@ -65,6 +66,11 @@ func (g *TemplateGenerator) Generate(opts GenerateOptions) error { // until the next Workflow module release is published. opts.LegacyContracts = true } + for _, contract := range opts.MessageContracts { + if _, err := contract.ContractDescriptor(); err != nil { + return fmt.Errorf("message contract %q: %w", contract.ContractType, err) + } + } // Validate the name manifest := &plugin.PluginManifest{ @@ -168,7 +174,11 @@ func generateProjectStructure(opts GenerateOptions) error { if err := writeFile(filepath.Join(protoDir, protoFileName(shortName)), generateProtoContract(goModule, shortName), 0600); err != nil { return err } - if err := writeFile(filepath.Join(opts.OutputDir, "plugin.contracts.json"), generatePluginContractsJSON(shortName), 0600); err != nil { + if err := writeFile(filepath.Join(opts.OutputDir, "plugin.contracts.json"), generatePluginContractsJSON(shortName, true, opts.MessageContracts), 0600); err != nil { + return err + } + } else if len(opts.MessageContracts) > 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