diff --git a/.github/workflows/smoke-call-workflow.lock.yml b/.github/workflows/smoke-call-workflow.lock.yml index 1a62f2dc909..5521880e9a2 100644 --- a/.github/workflows/smoke-call-workflow.lock.yml +++ b/.github/workflows/smoke-call-workflow.lock.yml @@ -674,6 +674,23 @@ jobs: } GH_AW_VALIDATION_JSON: | { + "call_workflow": { + "defaultMax": 1, + "fields": { + "inputs": { + "type": "object" + }, + "workflow_name": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256, + "minLength": 1, + "pattern": ".*\\S.*", + "patternError": "must not be empty" + } + } + }, "missing_data": { "defaultMax": 20, "fields": { diff --git a/actions/setup/js/collect_ndjson_output.test.cjs b/actions/setup/js/collect_ndjson_output.test.cjs index 5f56b961804..a92bf1cf442 100644 --- a/actions/setup/js/collect_ndjson_output.test.cjs +++ b/actions/setup/js/collect_ndjson_output.test.cjs @@ -133,6 +133,13 @@ describe("collect_ndjson_output.cjs", () => { inputs: { type: "object" }, }, }, + call_workflow: { + defaultMax: 1, + fields: { + workflow_name: { required: !0, type: "string", sanitize: !0, minLength: 1, maxLength: 256, pattern: ".*\\S.*", patternError: "must not be empty" }, + inputs: { type: "object" }, + }, + }, }) )); }), @@ -261,6 +268,28 @@ describe("collect_ndjson_output.cjs", () => { const parsedOutput = JSON.parse(outputCall[1]); expect(parsedOutput.errors).toHaveLength(1); }), + it("should preserve call_workflow workflow_name and inputs during ingestion (regression for github/gh-aw#55176)", async () => { + // Samples-mode replay (and the live dynamic call_workflow MCP tool) emits a + // canonical message with both workflow_name and inputs set. Before the fix, + // call_workflow had no ValidationConfig entry, so ingestion fell back to + // validateItemWithSafeJobConfig, which dropped every field except "type" + // because the call_workflow safe-outputs config lacks an "inputs" key. + const testFile = "/tmp/gh-aw/test-ndjson-output.txt", + ndjsonContent = '{"type": "call_workflow", "workflow_name": "test-copilot-call-worker", "inputs": {"sentinel": "hello-sentinel"}}'; + (fs.writeFileSync(testFile, ndjsonContent), (process.env.GH_AW_SAFE_OUTPUTS = testFile)); + const __config = '{"call_workflow":{"max":1,"workflows":["test-copilot-call-worker"],"workflow_files":{"test-copilot-call-worker":"./.github/workflows/test-copilot-call-worker.lock.yml"}}}', + configPath = "/tmp/gh-aw/safeoutputs/config.json"; + (fs.mkdirSync("/tmp/gh-aw/safeoutputs", { recursive: !0 }), fs.writeFileSync(configPath, __config), await eval(`(async () => { ${collectScript}; await main(); })()`)); + const setOutputCalls = mockCore.setOutput.mock.calls, + outputCall = setOutputCalls.find(call => "output" === call[0]); + expect(outputCall).toBeDefined(); + const parsedOutput = JSON.parse(outputCall[1]); + (expect(parsedOutput.items).toHaveLength(1), + expect(parsedOutput.items[0].type).toBe("call_workflow"), + expect(parsedOutput.items[0].workflow_name).toBe("test-copilot-call-worker"), + expect(parsedOutput.items[0].inputs).toEqual({ sentinel: "hello-sentinel" }), + expect(parsedOutput.errors).toHaveLength(0)); + }), it("should preserve Slack mrkdwn links in custom safe-job string inputs", async () => { const testFile = "/tmp/gh-aw/test-ndjson-output.txt"; const slackText = "Tracking issue: "; diff --git a/pkg/workflow/safe_output_validation_config_test.go b/pkg/workflow/safe_output_validation_config_test.go index 3d835c65a16..2eb94284dd6 100644 --- a/pkg/workflow/safe_output_validation_config_test.go +++ b/pkg/workflow/safe_output_validation_config_test.go @@ -102,6 +102,48 @@ func TestApproveWorkflowRunValidationConfig(t *testing.T) { } } +// TestCallWorkflowValidationConfigPreservesWorkflowName is a regression test for +// samples-mode call-workflow replay dropping the workflow name (github/gh-aw#55176): +// without a "call_workflow" entry in ValidationConfig, collect_ndjson_output.cjs +// fell back to validateItemWithSafeJobConfig, which drops every field except +// "type" because the call_workflow safe-outputs config has no "inputs" key. This +// test verifies the compiler emits a validation config that declares +// "workflow_name" (required) and "inputs" for call_workflow, mirroring +// dispatch_workflow, so the field survives ingestion. +func TestCallWorkflowValidationConfigPreservesWorkflowName(t *testing.T) { + config, ok := ValidationConfig["call_workflow"] + if !ok { + t.Fatal("call_workflow not found in ValidationConfig") + } + if config.DefaultMax != 1 { + t.Errorf("call_workflow DefaultMax = %d, want 1", config.DefaultMax) + } + workflowName, ok := config.Fields["workflow_name"] + if !ok || !workflowName.Required || workflowName.Type != "string" { + t.Errorf("call_workflow workflow_name = %+v, want required string", workflowName) + } + inputs, ok := config.Fields["inputs"] + if !ok || inputs.Type != "object" { + t.Errorf("call_workflow inputs = %+v, want object", inputs) + } + + jsonStr, err := GetValidationConfigJSONWithDataSchema([]string{"call_workflow"}, nil, false, nil) + if err != nil { + t.Fatalf("GetValidationConfigJSONWithDataSchema() error = %v", err) + } + var parsed map[string]TypeValidationConfig + if err := json.Unmarshal([]byte(jsonStr), &parsed); err != nil { + t.Fatalf("Failed to parse validation config JSON: %v", err) + } + parsedConfig, ok := parsed["call_workflow"] + if len(parsed) != 1 || !ok || parsedConfig.DefaultMax != 1 { + t.Errorf("call_workflow validation config = %#v, want defaultMax 1", parsedConfig) + } + if workflowName := parsedConfig.Fields["workflow_name"]; !workflowName.Required || workflowName.Type != "string" { + t.Errorf("generated call_workflow workflow_name = %+v, want required string", workflowName) + } +} + func TestDismissPullRequestReviewValidationConfigSupportsAutoReviewID(t *testing.T) { config, ok := ValidationConfig["dismiss_pull_request_review"] if !ok { diff --git a/pkg/workflow/safe_outputs_validation_config.go b/pkg/workflow/safe_outputs_validation_config.go index 461ba325db9..9fbc913ea6e 100644 --- a/pkg/workflow/safe_outputs_validation_config.go +++ b/pkg/workflow/safe_outputs_validation_config.go @@ -450,6 +450,13 @@ var ValidationConfig = map[string]TypeValidationConfig{ "ref": {Type: "string", MinLength: 1, MaxLength: 256, Pattern: "^[^\\x00-\\x20\\x7f~^:?*\\[\\\\]+$", PatternError: "must be a valid git ref"}, }, }, + "call_workflow": { + DefaultMax: 1, + Fields: map[string]FieldValidation{ + "workflow_name": {Required: true, Type: "string", Sanitize: true, MinLength: 1, MaxLength: 256, Pattern: ".*\\S.*", PatternError: "must not be empty"}, + "inputs": {Type: "object"}, + }, + }, "missing_tool": { DefaultMax: 20, Fields: map[string]FieldValidation{