diff --git a/actions/setup/sh/conclude_threat_detection.sh b/actions/setup/sh/conclude_threat_detection.sh index e3159664639..8920b5f665b 100755 --- a/actions/setup/sh/conclude_threat_detection.sh +++ b/actions/setup/sh/conclude_threat_detection.sh @@ -10,7 +10,14 @@ DETECTION_STATUS_PREFIX="THREAT_DETECTION_STATUS:" continue_on_error="${GH_AW_DETECTION_CONTINUE_ON_ERROR:-true}" continue_on_error="$(echo "${continue_on_error}" | tr '[:upper:]' '[:lower:]')" -if [ "${RUN_DETECTION:-false}" = "true" ] && [ ! -f "${RESULT_FILE}" ]; then +if [ "${RUN_DETECTION:-false}" != "true" ]; then + echo "conclusion=skipped" >> "${GITHUB_OUTPUT}" + echo "success=true" >> "${GITHUB_OUTPUT}" + echo "reason=" >> "${GITHUB_OUTPUT}" + exit 0 +fi + +if [ ! -f "${RESULT_FILE}" ]; then detection_status="" if [ -f "${DETECTION_LOG_FILE}" ]; then detection_status="$(grep "${DETECTION_STATUS_PREFIX}" "${DETECTION_LOG_FILE}" | tail -n 1 || true)" @@ -30,8 +37,6 @@ if [ "${RUN_DETECTION:-false}" = "true" ] && [ ! -f "${RESULT_FILE}" ]; then echo "conclusion=warning" >> "${GITHUB_OUTPUT}" echo "success=false" >> "${GITHUB_OUTPUT}" echo "reason=agent_failure" >> "${GITHUB_OUTPUT}" - echo "GH_AW_DETECTION_CONCLUSION=warning" >> "${GITHUB_ENV}" - echo "GH_AW_DETECTION_REASON=agent_failure" >> "${GITHUB_ENV}" exit 0 fi echo "ERR_SYSTEM: ❌ ${result_message}" diff --git a/pkg/workflow/threat_detection_conclude_script_test.go b/pkg/workflow/threat_detection_conclude_script_test.go index 902fd51d027..d3274648150 100644 --- a/pkg/workflow/threat_detection_conclude_script_test.go +++ b/pkg/workflow/threat_detection_conclude_script_test.go @@ -13,7 +13,6 @@ const detectorStatusLine = "THREAT_DETECTION_STATUS: reason=engine_error exit=2" func TestConcludeThreatDetectionScript_MissingResultContinueOnError(t *testing.T) { scriptPath := filepath.Join("..", "..", "actions", "setup", "sh", "conclude_threat_detection.sh") outputFile := filepath.Join(t.TempDir(), "github_output.txt") - envFile := filepath.Join(t.TempDir(), "github_env.txt") missingResult := filepath.Join(t.TempDir(), "missing_detection_result.json") cmd := exec.Command("bash", scriptPath, missingResult) @@ -22,7 +21,6 @@ func TestConcludeThreatDetectionScript_MissingResultContinueOnError(t *testing.T "DETECTION_AGENTIC_EXECUTION_OUTCOME=failure", "GH_AW_DETECTION_CONTINUE_ON_ERROR=TRUE", "GITHUB_OUTPUT="+outputFile, - "GITHUB_ENV="+envFile, ) out, err := cmd.CombinedOutput() @@ -44,18 +42,6 @@ func TestConcludeThreatDetectionScript_MissingResultContinueOnError(t *testing.T if !strings.Contains(outputText, "reason=agent_failure") { t.Fatalf("expected reason=agent_failure in GITHUB_OUTPUT, got: %s", outputText) } - - envData, err := os.ReadFile(envFile) - if err != nil { - t.Fatalf("failed to read GITHUB_ENV: %v", err) - } - envText := string(envData) - if !strings.Contains(envText, "GH_AW_DETECTION_CONCLUSION=warning") { - t.Fatalf("expected warning conclusion in GITHUB_ENV, got: %s", envText) - } - if !strings.Contains(envText, "GH_AW_DETECTION_REASON=agent_failure") { - t.Fatalf("expected agent_failure reason in GITHUB_ENV, got: %s", envText) - } if !strings.Contains(string(out), "continuing because GH_AW_DETECTION_CONTINUE_ON_ERROR=true") { t.Fatalf("expected warning message about continue-on-error, got: %s", out) } @@ -65,7 +51,6 @@ func TestConcludeThreatDetectionScript_MissingResultSurfacesDetectorStatus(t *te tempDir := t.TempDir() scriptPath := filepath.Join("..", "..", "actions", "setup", "sh", "conclude_threat_detection.sh") outputFile := filepath.Join(tempDir, "github_output.txt") - envFile := filepath.Join(tempDir, "github_env.txt") missingResult := filepath.Join(tempDir, "missing_detection_result.json") detectionLog := filepath.Join(tempDir, "detection.log") @@ -80,7 +65,6 @@ func TestConcludeThreatDetectionScript_MissingResultSurfacesDetectorStatus(t *te "GH_AW_DETECTION_CONTINUE_ON_ERROR=true", "DETECTION_LOG_FILE="+detectionLog, "GITHUB_OUTPUT="+outputFile, - "GITHUB_ENV="+envFile, ) out, err := cmd.CombinedOutput() @@ -94,17 +78,96 @@ func TestConcludeThreatDetectionScript_MissingResultSurfacesDetectorStatus(t *te if !strings.Contains(string(out), "execution outcome: failure") { t.Fatalf("expected warning output to include execution outcome, got: %s", out) } +} + +func TestConcludeThreatDetectionScript_SkippedWhenRunDetectionFalse(t *testing.T) { + tmpDir := t.TempDir() + scriptPath := filepath.Join("..", "..", "actions", "setup", "sh", "conclude_threat_detection.sh") + outputFile := filepath.Join(tmpDir, "github_output.txt") + // No result file written — threat-detect is not installed — both should be irrelevant. + missingResult := filepath.Join(tmpDir, "missing_detection_result.json") + + // Use a PATH that does not include threat-detect by prepending a + // sentinel bin dir that contains no executables. The guard must + // exit before ever reaching the threat-detect invocation. + emptyBinDir := filepath.Join(tmpDir, "empty-bin") + if err := os.MkdirAll(emptyBinDir, 0755); err != nil { + t.Fatalf("failed to create empty bin dir: %v", err) + } - envData, err := os.ReadFile(envFile) + cmd := exec.Command("bash", scriptPath, missingResult) + cmd.Env = append(os.Environ(), + "RUN_DETECTION=false", + "GITHUB_OUTPUT="+outputFile, + "PATH="+emptyBinDir+":"+os.Getenv("PATH"), + ) + + out, err := cmd.CombinedOutput() if err != nil { - t.Fatalf("failed to read GITHUB_ENV: %v", err) + t.Fatalf("script should exit 0 when RUN_DETECTION=false: %v\nOutput: %s", err, out) + } + + outputData, err := os.ReadFile(outputFile) + if err != nil { + t.Fatalf("failed to read GITHUB_OUTPUT: %v", err) + } + outputText := string(outputData) + if !strings.Contains(outputText, "conclusion=skipped") { + t.Fatalf("expected conclusion=skipped in GITHUB_OUTPUT, got: %s", outputText) + } + if !strings.Contains(outputText, "success=true") { + t.Fatalf("expected success=true in GITHUB_OUTPUT, got: %s", outputText) + } + if !strings.Contains(outputText, "reason=") { + t.Fatalf("expected reason= in GITHUB_OUTPUT, got: %s", outputText) + } +} + +func TestConcludeThreatDetectionScript_SkippedWhenRunDetectionUnset(t *testing.T) { + tmpDir := t.TempDir() + scriptPath := filepath.Join("..", "..", "actions", "setup", "sh", "conclude_threat_detection.sh") + outputFile := filepath.Join(tmpDir, "github_output.txt") + missingResult := filepath.Join(tmpDir, "missing_detection_result.json") + + emptyBinDir := filepath.Join(tmpDir, "empty-bin") + if err := os.MkdirAll(emptyBinDir, 0755); err != nil { + t.Fatalf("failed to create empty bin dir: %v", err) + } + + // Build an environment that does NOT include RUN_DETECTION at all, + // verifying that the ${RUN_DETECTION:-false} default also triggers the guard. + filteredEnv := make([]string, 0, len(os.Environ())) + for _, e := range os.Environ() { + if !strings.HasPrefix(e, "RUN_DETECTION=") { + filteredEnv = append(filteredEnv, e) + } + } + filteredEnv = append(filteredEnv, + "GITHUB_OUTPUT="+outputFile, + "PATH="+emptyBinDir+":"+os.Getenv("PATH"), + ) + + cmd := exec.Command("bash", scriptPath, missingResult) + cmd.Env = filteredEnv + + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("script should exit 0 when RUN_DETECTION is unset: %v\nOutput: %s", err, out) + } + + outputData, err := os.ReadFile(outputFile) + if err != nil { + t.Fatalf("failed to read GITHUB_OUTPUT: %v", err) + } + outputText := string(outputData) + if !strings.Contains(outputText, "conclusion=skipped") { + t.Fatalf("expected conclusion=skipped in GITHUB_OUTPUT, got: %s", outputText) } - envText := string(envData) - if !strings.Contains(envText, "GH_AW_DETECTION_CONCLUSION=warning") { - t.Fatalf("expected warning conclusion in GITHUB_ENV, got: %s", envText) + if !strings.Contains(outputText, "success=true") { + t.Fatalf("expected success=true in GITHUB_OUTPUT, got: %s", outputText) } - if !strings.Contains(envText, "GH_AW_DETECTION_REASON=agent_failure") { - t.Fatalf("expected agent_failure reason in GITHUB_ENV, got: %s", envText) + if !strings.Contains(outputText, "reason=") { + t.Fatalf("expected reason= in GITHUB_OUTPUT, got: %s", outputText) } } diff --git a/pkg/workflow/threat_detection_external.go b/pkg/workflow/threat_detection_external.go index 7c4c8f32ca7..b313a2f4dbd 100644 --- a/pkg/workflow/threat_detection_external.go +++ b/pkg/workflow/threat_detection_external.go @@ -395,8 +395,9 @@ func (c *Compiler) buildUploadDetectionArtifactStep(data *WorkflowData) []string // buildExternalDetectorConcludeStep creates the conclude step for the external // threat-detect binary. It runs `threat-detect conclude --result-file ...` which reads // the structured detection_result.json and sets the detection_conclusion/detection_reason/ -// detection_success step outputs and exports GH_AW_DETECTION_CONCLUSION/GH_AW_DETECTION_REASON, -// preserving the same gate contract as the inline parse_threat_detection_results.cjs path. +// detection_success step outputs, preserving the same gate contract as the inline +// parse_threat_detection_results.cjs path. Outputs (not env vars) are used exclusively; +// downstream jobs consume these via needs.detection.outputs.* expressions. // The step ID (detection_conclusion) and env vars (RUN_DETECTION, DETECTION_AGENTIC_EXECUTION_OUTCOME, // GH_AW_DETECTION_CONTINUE_ON_ERROR) are byte-identical to the inline conclude step. func (c *Compiler) buildExternalDetectorConcludeStep(data *WorkflowData) []string {