Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions .github/workflows/eslint-monster.lock.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 4 additions & 4 deletions .github/workflows/lint-monster.lock.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

157 changes: 157 additions & 0 deletions pkg/workflow/compiler_jobs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4071,3 +4071,160 @@ func TestBuildMainJobEngineEnvActivationNoFalseWarning(t *testing.T) {
assert.Equal(t, initialWarnings, compiler.GetWarningCount(),
"no warning should be emitted for activation which is already a direct agent dependency")
}

// TestBuildDetectionJobEngineEnvNeedsExpression verifies that when engine.env values contain
// needs.<customJob>.outputs.* expressions, the referenced custom job is added as a direct
// dependency of the detection job (mirrors the same fix applied to the agent job).
func TestBuildDetectionJobEngineEnvNeedsExpression(t *testing.T) {
compiler := NewCompiler()
compiler.stepOrderTracker = NewStepOrderTracker()

workflowData := &WorkflowData{
Name: "Test Workflow",
AI: "copilot",
RunsOn: "runs-on: ubuntu-latest",
Permissions: "permissions:\n contents: read",
EngineConfig: &EngineConfig{
ID: "copilot",
Env: map[string]string{
"COPILOT_MODEL": "${{ needs.select_model.outputs.model }}",
},
},
Jobs: map[string]any{
"select_model": map[string]any{
"runs-on": "ubuntu-latest",
"needs": "pre_activation",
"outputs": map[string]any{
"model": "${{ steps.pick.outputs.model }}",
},
"steps": []any{
map[string]any{
"id": "pick",
"run": `echo "model=claude-sonnet-4.6" >> "$GITHUB_OUTPUT"`,
},
},
},
},
SafeOutputs: &SafeOutputsConfig{
ThreatDetection: &ThreatDetectionConfig{},
Jobs: map[string]*SafeJobConfig{
"create-issue": {},
},
},
}

job, err := compiler.buildDetectionJob(workflowData)
require.NoError(t, err, "buildDetectionJob should succeed")
require.NotNil(t, job, "detection job should be created")

// The detection job must directly depend on select_model because engine.env
// references its outputs; without this, needs.select_model would be undefined.
assert.Contains(t, job.Needs, "select_model",
"detection job must directly depend on select_model referenced in engine.env")
assert.Contains(t, job.Needs, string(constants.AgentJobName),
"detection job must still depend on agent")
assert.Contains(t, job.Needs, string(constants.ActivationJobName),
"detection job must still depend on activation")
}

// TestBuildDetectionJobEngineEnvNeedsNotDuplicated verifies that a job referenced in
// engine.env is not duplicated in the detection job's needs list.
func TestBuildDetectionJobEngineEnvNeedsNotDuplicated(t *testing.T) {
compiler := NewCompiler()
compiler.stepOrderTracker = NewStepOrderTracker()

workflowData := &WorkflowData{
Name: "Test Workflow",
AI: "copilot",
RunsOn: "runs-on: ubuntu-latest",
Permissions: "permissions:\n contents: read",
EngineConfig: &EngineConfig{
ID: "copilot",
Env: map[string]string{
"VALUE_A": "${{ needs.custom_job.outputs.result }}",
"VALUE_B": "${{ needs.custom_job.outputs.other }}",
},
},
Jobs: map[string]any{
"custom_job": map[string]any{
"runs-on": "ubuntu-latest",
"steps": []any{
map[string]any{"run": "echo result=hello >> $GITHUB_OUTPUT"},
},
},
},
SafeOutputs: &SafeOutputsConfig{
ThreatDetection: &ThreatDetectionConfig{},
Jobs: map[string]*SafeJobConfig{"create-issue": {}},
},
}

job, err := compiler.buildDetectionJob(workflowData)
require.NoError(t, err, "buildDetectionJob should succeed")
require.NotNil(t, job, "detection job should be created")

count := 0
for _, need := range job.Needs {
if need == "custom_job" {
count++
}
}
assert.Equal(t, 1, count, "custom_job should appear exactly once in detection needs")
}

// TestBuildDetectionJobEngineEnvNeedsIntegration is an end-to-end integration test that
// compiles a workflow where engine.env references a custom job output, and verifies that the
// compiled lock file includes the custom job as a direct dependency of the detection job.
func TestBuildDetectionJobEngineEnvNeedsIntegration(t *testing.T) {
tmpDir := testutil.TempDir(t, "detection_engine_env_needs_test")

frontmatter := `---
on:
issues:
types: [opened]
permissions: {}
permissions:
contents: read
issues: read
engine:
id: copilot
env:
COPILOT_MODEL: ${{ needs.select_model.outputs.model }}
strict: false
jobs:
select_model:
runs-on: ubuntu-latest
needs: pre_activation
outputs:
model: ${{ steps.pick.outputs.model }}
steps:
- id: pick
run: echo "model=claude-sonnet-4.6" >> "$GITHUB_OUTPUT"
safe-outputs:
create-issue: {}
---

# Test Detection Needs

This workflow tests that engine.env needs expressions create detection job dependencies.
`

testFile := filepath.Join(tmpDir, "detection-engine-env-needs.md")
require.NoError(t, os.WriteFile(testFile, []byte(frontmatter), 0644), "write test file")

compiler := NewCompiler()
require.NoError(t, compiler.CompileWorkflow(testFile), "compile workflow")

lockFile := filepath.Join(tmpDir, "detection-engine-env-needs.lock.yml")
content, err := os.ReadFile(lockFile)
require.NoError(t, err, "read lock file")

yamlStr := string(content)

// The detection job must directly depend on select_model
detectionSection := extractJobSection(yamlStr, "detection")
require.NotEmpty(t, detectionSection, "detection job section should be present in lock file")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] The integration test asserts that detection section contains the string select_model, but it doesn't verify the needs: key specifically. A false-positive could occur if select_model appeared elsewhere in the detection section (e.g., in a step env var or output value).

💡 Suggestion: tighten the assertion

Use a needs: prefix check to confirm the dependency is in the right place:

assert.Regexp(t, `(?m)needs:.*select_model`, detectionSection,
    "detection job must list select_model in its needs")

or, since the YAML needs list is rendered inline:

assert.Contains(t, detectionSection, "select_model",
    "detection job must list select_model in its needs (referenced via engine.env)")
// also confirm it appears on the needs line
assert.Regexp(t,
    `needs:.*\[.*select_model.*\]`,
    strings.Join(strings.Fields(detectionSection), " "),
    "select_model must be on the needs line, not just elsewhere in the detection section")

This is a minor robustness concern; the current test is still meaningful for regression detection.

@copilot please address this.


assert.Contains(t, detectionSection, "select_model",
"detection job must list select_model in its needs (referenced via engine.env)")

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in bd99f2e. The integration test now parses the compiled lock YAML and asserts jobs.detection.needs directly instead of relying on a substring match.

}
25 changes: 25 additions & 0 deletions pkg/workflow/threat_detection_job.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package workflow

import (
"fmt"
"slices"
"strings"

"github.com/github/gh-aw/pkg/constants"
)
Expand Down Expand Up @@ -68,6 +70,29 @@ func (c *Compiler) buildDetectionJob(data *WorkflowData) (*Job, error) {
// Detection job depends on agent job and activation job (for trace ID)
needs := []string{string(constants.AgentJobName), string(constants.ActivationJobName)}

// Scan engine.env values for needs.<customJob>.outputs.* expressions and add the
// referenced custom jobs as direct dependencies of the detection job.
// The detection job inherits the engine.env mapping from the main engine config, so
// it must also inherit the corresponding job dependencies to ensure those expressions
// evaluate correctly at runtime (mirrors the same scan done for the agent job).
if data.EngineConfig != nil && len(data.EngineConfig.Env) > 0 && len(data.Jobs) > 0 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/diagnosing-bugs] The agent job warns users when engine.env references a built-in job name in a needs expression (since built-ins cannot be added as direct dependencies and expressions silently resolve to empty). The detection job's new scan omits that same warning, so a misconfigured reference would still fail silently.

💡 Suggestion: mirror the built-in warning from compiler_main_job.go

After the dedup loop (~line 93), add the same built-in-name guard already in buildMainJob:

for _, builtinJobName := range sliceutil.SortedKeys(constants.KnownBuiltInJobNames) {
    if slices.Contains(needs, builtinJobName) {
        continue
    }
    if strings.Contains(engineEnvBuilder.String(), fmt.Sprintf("needs.%s.", builtinJobName)) {
        c.IncrementWarningCount()
        fmt.Fprintln(os.Stderr, console.FormatWarningMessage(
            fmt.Sprintf("engine.env references built-in job %q in a detection-job needs expression; will silently resolve to empty at runtime", builtinJobName)))
    }
}

This keeps detection-job behaviour consistent with the agent job and surfaces misconfigurations at compile time rather than at runtime.

@copilot please address this.

var engineEnvBuilder strings.Builder
for _, envValue := range data.EngineConfig.Env {
engineEnvBuilder.WriteByte('\n')
engineEnvBuilder.WriteString(envValue)
}
engineEnvJobs := c.getReferencedCustomJobs(engineEnvBuilder.String(), data.Jobs)
for _, jobName := range engineEnvJobs {
if isBuiltinJobName(jobName) {
continue
}
if !slices.Contains(needs, jobName) {
needs = append(needs, jobName)
threatLog.Printf("Added custom job '%s' to detection needs because it's referenced in engine.env", jobName)
}
}
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in bd99f2e. buildDetectionJob now scans the effective detection engine config, so safe-outputs.threat-detection.engine.env overrides the top-level engine env when computing detection needs.


// Determine runs-on: use threat detection override if set, otherwise ubuntu-latest.
// The detection job runs on a fresh runner separate from the agent job, so it does
// not need the same custom runner as safe-outputs.
Expand Down
Loading