diff --git a/pkg/constants/constants.go b/pkg/constants/constants.go index 9412ca7607..3f59b6f418 100644 --- a/pkg/constants/constants.go +++ b/pkg/constants/constants.go @@ -629,6 +629,10 @@ const APMJobName JobName = "apm" const IndexingJobName JobName = "indexing" const PreActivationJobName JobName = "pre_activation" const DetectionJobName JobName = "detection" +const SafeOutputsJobName JobName = "safe_outputs" +const UploadAssetsJobName JobName = "upload_assets" +const ConclusionJobName JobName = "conclusion" +const UnlockJobName JobName = "unlock" const SafeOutputArtifactName = "safe-output" const AgentOutputArtifactName = "agent-output" diff --git a/pkg/workflow/compiler.go b/pkg/workflow/compiler.go index 6a385e7c6f..109cc36269 100644 --- a/pkg/workflow/compiler.go +++ b/pkg/workflow/compiler.go @@ -174,6 +174,12 @@ func (c *Compiler) validateWorkflowData(workflowData *WorkflowData, markdownPath return formatCompilerError(markdownPath, "error", err.Error(), err) } + // Validate safe-job needs: declarations against known generated job IDs + log.Printf("Validating safe-job needs declarations") + if err := validateSafeJobNeeds(workflowData); err != nil { + return formatCompilerError(markdownPath, "error", err.Error(), err) + } + // Emit warnings for push-to-pull-request-branch misconfiguration log.Printf("Validating push-to-pull-request-branch configuration") c.validatePushToPullRequestBranchWarnings(workflowData.SafeOutputs, workflowData.CheckoutConfigs) diff --git a/pkg/workflow/safe_jobs_needs_validation.go b/pkg/workflow/safe_jobs_needs_validation.go new file mode 100644 index 0000000000..362cf4fcf1 --- /dev/null +++ b/pkg/workflow/safe_jobs_needs_validation.go @@ -0,0 +1,229 @@ +package workflow + +import ( + "fmt" + "sort" + "strings" + + "github.com/github/gh-aw/pkg/constants" + "github.com/github/gh-aw/pkg/logger" + "github.com/github/gh-aw/pkg/stringutil" +) + +var safeJobsNeedsValidationLog = logger.New("workflow:safe_jobs_needs_validation") + +// validateSafeJobNeeds validates the needs: declarations on custom safe-output jobs. +// +// For each custom safe-job, every entry in its needs: list must refer to a job that +// will actually exist in the compiled workflow. Valid targets are: +// +// - "agent" — main agent job (always present) +// - "detection" — threat-detection job (only when threat detection is enabled) +// - "safe_outputs" — consolidated safe-outputs job (only when builtin safe-output types, +// custom scripts, custom actions, or user-provided steps are configured) +// - "upload_assets"— upload-assets job (only when upload-asset is configured) +// - "unlock" — unlock job (only when lock-for-agent is enabled) +// - other custom safe-job names (normalised to underscore format) +// +// Each validated needs: entry is also rewritten to its normalized (underscore) form so +// that the compiled YAML references the correct job ID regardless of whether the author +// wrote "safe-outputs" or "safe_outputs". +// +// Additionally, cycles between custom safe-jobs are detected and reported as errors. +func validateSafeJobNeeds(data *WorkflowData) error { + if data.SafeOutputs == nil || len(data.SafeOutputs.Jobs) == 0 { + return nil + } + + safeJobsNeedsValidationLog.Printf("Validating needs: declarations for %d safe-jobs", len(data.SafeOutputs.Jobs)) + + validIDs := computeValidSafeJobNeeds(data) + + for originalName, jobConfig := range data.SafeOutputs.Jobs { + if jobConfig == nil || len(jobConfig.Needs) == 0 { + continue + } + + normalizedJobName := stringutil.NormalizeSafeOutputIdentifier(originalName) + for i, need := range jobConfig.Needs { + normalizedNeed := stringutil.NormalizeSafeOutputIdentifier(need) + if !validIDs[normalizedNeed] { + return fmt.Errorf( + "safe-outputs.jobs.%s: unknown needs target %q\n\nValid dependency targets for custom safe-jobs are:\n%s\n\n"+ + "Custom safe-jobs cannot depend on workflow control jobs such as 'conclusion' or 'activation'", + originalName, + need, + formatValidNeedsTargets(validIDs), + ) + } + // Prevent a job from listing itself as a dependency + if normalizedNeed == normalizedJobName { + return fmt.Errorf( + "safe-outputs.jobs.%s: a job cannot depend on itself in needs", + originalName, + ) + } + // Rewrite the needs entry to its canonical underscore form so the compiled + // YAML references the correct job ID (e.g. "safe-outputs" → "safe_outputs"). + jobConfig.Needs[i] = normalizedNeed + } + } + + // Detect cycles between custom safe-jobs + if err := detectSafeJobCycles(data.SafeOutputs.Jobs); err != nil { + return err + } + + safeJobsNeedsValidationLog.Print("safe-job needs: validation passed") + return nil +} + +// computeValidSafeJobNeeds returns the set of job IDs that custom safe-jobs are +// allowed to depend on, based on the workflow configuration. +func computeValidSafeJobNeeds(data *WorkflowData) map[string]bool { + valid := map[string]bool{ + string(constants.AgentJobName): true, // agent is always present + } + + if data.SafeOutputs == nil { + return valid + } + + // safe_outputs consolidated job only exists when builtin safe-output types, custom scripts, + // custom actions, or user-provided steps are configured. Custom safe-jobs (safe-outputs.jobs) + // compile to separate jobs and do NOT create steps in the consolidated job. + if consolidatedSafeOutputsJobWillExist(data.SafeOutputs) { + valid[string(constants.SafeOutputsJobName)] = true + } + + // detection job exists when threat detection is enabled + if IsDetectionJobEnabled(data.SafeOutputs) { + valid[string(constants.DetectionJobName)] = true + } + + // upload_assets job exists when upload-asset is configured + if data.SafeOutputs.UploadAssets != nil { + valid[string(constants.UploadAssetsJobName)] = true + } + + // unlock job exists when lock-for-agent is enabled + if data.LockForAgent { + valid[string(constants.UnlockJobName)] = true + } + + // other custom safe-job names (normalized) are also valid targets + for jobName := range data.SafeOutputs.Jobs { + normalized := stringutil.NormalizeSafeOutputIdentifier(jobName) + valid[normalized] = true + } + + return valid +} + +// consolidatedSafeOutputsJobWillExist returns true when the compiled workflow will include +// a "safe_outputs" job. The consolidated job is generated only when at least one builtin +// safe-output handler, custom script, custom action, or user-provided step is configured. +// Custom safe-jobs (safe-outputs.jobs) compile to SEPARATE jobs and therefore do not cause +// the consolidated safe_outputs job to be emitted. +func consolidatedSafeOutputsJobWillExist(safeOutputs *SafeOutputsConfig) bool { + if safeOutputs == nil { + return false + } + // Scripts, actions, and user-provided steps always add to the consolidated job. + if len(safeOutputs.Scripts) > 0 || len(safeOutputs.Actions) > 0 || len(safeOutputs.Steps) > 0 { + return true + } + // Reuse the existing reflection-based check with the dynamic fields cleared. + // hasAnySafeOutputEnabled will then fall through to reflection over safeOutputFieldMapping, + // which covers every builtin pointer type (create-issue, add-comment, etc.). + stripped := *safeOutputs + stripped.Jobs = nil + stripped.Scripts = nil + stripped.Actions = nil + stripped.Steps = nil + return hasAnySafeOutputEnabled(&stripped) +} + +// formatValidNeedsTargets returns a human-readable, sorted list of valid need targets. +func formatValidNeedsTargets(validIDs map[string]bool) string { + targets := make([]string, 0, len(validIDs)) + for id := range validIDs { + targets = append(targets, " - "+id) + } + sort.Strings(targets) + return strings.Join(targets, "\n") +} + +// detectSafeJobCycles checks for dependency cycles among custom safe-jobs using DFS. +func detectSafeJobCycles(jobs map[string]*SafeJobConfig) error { + if len(jobs) == 0 { + return nil + } + + // Build normalized name mapping + normalized := make(map[string]*SafeJobConfig, len(jobs)) + originalNames := make(map[string]string, len(jobs)) + for name, cfg := range jobs { + n := stringutil.NormalizeSafeOutputIdentifier(name) + normalized[n] = cfg + originalNames[n] = name + } + + const ( + unvisited = 0 + visiting = 1 + visited = 2 + ) + state := make(map[string]int, len(normalized)) + + var dfs func(node string, path []string) error + dfs = func(node string, path []string) error { + if state[node] == visited { + return nil + } + if state[node] == visiting { + // Build the cycle description using original names where available + cycleNodes := make([]string, 0, len(path)+1) + for _, p := range path { + if orig, ok := originalNames[p]; ok { + cycleNodes = append(cycleNodes, orig) + } else { + cycleNodes = append(cycleNodes, p) + } + } + origNode := node + if orig, ok := originalNames[node]; ok { + origNode = orig + } + cycleNodes = append(cycleNodes, origNode) + return fmt.Errorf( + "safe-outputs.jobs: dependency cycle detected: %s", + strings.Join(cycleNodes, " → "), + ) + } + + state[node] = visiting + cfg, exists := normalized[node] + if exists && cfg != nil { + for _, dep := range cfg.Needs { + depNorm := stringutil.NormalizeSafeOutputIdentifier(dep) + // Only recurse into other custom safe-jobs; skip generated jobs + if _, isSafeJob := normalized[depNorm]; isSafeJob { + if err := dfs(depNorm, append(path, node)); err != nil { + return err + } + } + } + } + state[node] = visited + return nil + } + + for node := range normalized { + if err := dfs(node, nil); err != nil { + return err + } + } + + return nil +} diff --git a/pkg/workflow/safe_jobs_needs_validation_test.go b/pkg/workflow/safe_jobs_needs_validation_test.go new file mode 100644 index 0000000000..7024a595b8 --- /dev/null +++ b/pkg/workflow/safe_jobs_needs_validation_test.go @@ -0,0 +1,462 @@ +//go:build !integration + +package workflow + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestValidateSafeJobNeeds_NoSafeOutputs verifies that validation is a no-op +// when there are no safe-outputs or no custom jobs. +func TestValidateSafeJobNeeds_NoSafeOutputs(t *testing.T) { + data := &WorkflowData{} + require.NoError(t, validateSafeJobNeeds(data), "no safe-outputs config: should pass") + + data.SafeOutputs = &SafeOutputsConfig{} + require.NoError(t, validateSafeJobNeeds(data), "nil jobs map: should pass") + + data.SafeOutputs.Jobs = map[string]*SafeJobConfig{} + require.NoError(t, validateSafeJobNeeds(data), "zero-length jobs map: should pass") +} + +// TestValidateSafeJobNeeds_ValidTargets verifies that valid targets are accepted. +func TestValidateSafeJobNeeds_ValidTargets(t *testing.T) { + tests := []struct { + name string + data *WorkflowData + wantErr bool + errContains string + }{ + { + name: "needs safe_outputs – valid when builtin type is configured", + data: &WorkflowData{ + SafeOutputs: &SafeOutputsConfig{ + CreateIssues: &CreateIssuesConfig{}, // creates the consolidated safe_outputs job + Jobs: map[string]*SafeJobConfig{ + "packaging": { + Needs: []string{"safe_outputs"}, + Steps: []any{map[string]any{"run": "echo hi"}}, + }, + }, + }, + }, + }, + { + name: "needs safe_outputs – invalid when only custom jobs are configured", + wantErr: true, + errContains: "unknown needs target", + data: &WorkflowData{ + SafeOutputs: &SafeOutputsConfig{ + // No builtin types, no scripts, no actions, no user steps. + // Only custom jobs → safe_outputs job will NOT be compiled. + Jobs: map[string]*SafeJobConfig{ + "packaging": { + Needs: []string{"safe_outputs"}, + Steps: []any{map[string]any{"run": "echo hi"}}, + }, + }, + }, + }, + }, + { + name: "needs agent – always valid", + data: &WorkflowData{ + SafeOutputs: &SafeOutputsConfig{ + Jobs: map[string]*SafeJobConfig{ + "packaging": { + Needs: []string{"agent"}, + Steps: []any{map[string]any{"run": "echo hi"}}, + }, + }, + }, + }, + }, + { + name: "needs detection – valid when threat detection enabled", + data: &WorkflowData{ + SafeOutputs: &SafeOutputsConfig{ + // Non-nil ThreatDetection means detection is enabled + ThreatDetection: &ThreatDetectionConfig{}, + Jobs: map[string]*SafeJobConfig{ + "packaging": { + Needs: []string{"detection"}, + Steps: []any{map[string]any{"run": "echo hi"}}, + }, + }, + }, + }, + }, + { + name: "needs upload_assets – valid when upload-asset is configured", + data: &WorkflowData{ + SafeOutputs: &SafeOutputsConfig{ + UploadAssets: &UploadAssetsConfig{}, + Jobs: map[string]*SafeJobConfig{ + "packaging": { + Needs: []string{"upload_assets"}, + Steps: []any{map[string]any{"run": "echo hi"}}, + }, + }, + }, + }, + }, + { + name: "needs unlock – valid when lock-for-agent is enabled", + data: &WorkflowData{ + LockForAgent: true, + SafeOutputs: &SafeOutputsConfig{ + Jobs: map[string]*SafeJobConfig{ + "packaging": { + Needs: []string{"unlock"}, + Steps: []any{map[string]any{"run": "echo hi"}}, + }, + }, + }, + }, + }, + { + name: "needs another custom safe-job – valid", + data: &WorkflowData{ + SafeOutputs: &SafeOutputsConfig{ + Jobs: map[string]*SafeJobConfig{ + "first_job": { + Steps: []any{map[string]any{"run": "echo hi"}}, + }, + "second_job": { + Needs: []string{"first_job"}, + Steps: []any{map[string]any{"run": "echo there"}}, + }, + }, + }, + }, + }, + { + name: "needs another custom safe-job with dashes in source – valid, normalized", + data: &WorkflowData{ + SafeOutputs: &SafeOutputsConfig{ + Jobs: map[string]*SafeJobConfig{ + "first-job": { + Steps: []any{map[string]any{"run": "echo hi"}}, + }, + "second-job": { + Needs: []string{"first-job"}, // dash form; should be accepted and normalized + Steps: []any{map[string]any{"run": "echo there"}}, + }, + }, + }, + }, + }, + { + name: "needs unknown job – should fail", + wantErr: true, + errContains: "unknown needs target", + data: &WorkflowData{ + SafeOutputs: &SafeOutputsConfig{ + Jobs: map[string]*SafeJobConfig{ + "packaging": { + Needs: []string{"nonexistent_job"}, + Steps: []any{map[string]any{"run": "echo hi"}}, + }, + }, + }, + }, + }, + { + name: "needs conclusion – should fail (not a valid target)", + wantErr: true, + errContains: "unknown needs target", + data: &WorkflowData{ + SafeOutputs: &SafeOutputsConfig{ + Jobs: map[string]*SafeJobConfig{ + "packaging": { + Needs: []string{"conclusion"}, + Steps: []any{map[string]any{"run": "echo hi"}}, + }, + }, + }, + }, + }, + { + name: "needs activation – should fail (not a valid target)", + wantErr: true, + errContains: "unknown needs target", + data: &WorkflowData{ + SafeOutputs: &SafeOutputsConfig{ + Jobs: map[string]*SafeJobConfig{ + "packaging": { + Needs: []string{"activation"}, + Steps: []any{map[string]any{"run": "echo hi"}}, + }, + }, + }, + }, + }, + { + name: "needs detection – invalid when threat detection disabled", + wantErr: true, + errContains: "unknown needs target", + data: &WorkflowData{ + SafeOutputs: &SafeOutputsConfig{ + // ThreatDetection nil means explicitly disabled + ThreatDetection: nil, + Jobs: map[string]*SafeJobConfig{ + "packaging": { + Needs: []string{"detection"}, + Steps: []any{map[string]any{"run": "echo hi"}}, + }, + }, + }, + }, + }, + { + name: "needs upload_assets – invalid when upload-asset not configured", + wantErr: true, + errContains: "unknown needs target", + data: &WorkflowData{ + SafeOutputs: &SafeOutputsConfig{ + Jobs: map[string]*SafeJobConfig{ + "packaging": { + Needs: []string{"upload_assets"}, + Steps: []any{map[string]any{"run": "echo hi"}}, + }, + }, + }, + }, + }, + { + name: "needs unlock – invalid when lock-for-agent disabled", + wantErr: true, + errContains: "unknown needs target", + data: &WorkflowData{ + LockForAgent: false, + SafeOutputs: &SafeOutputsConfig{ + Jobs: map[string]*SafeJobConfig{ + "packaging": { + Needs: []string{"unlock"}, + Steps: []any{map[string]any{"run": "echo hi"}}, + }, + }, + }, + }, + }, + { + name: "self-dependency – should fail", + wantErr: true, + errContains: "cannot depend on itself", + data: &WorkflowData{ + SafeOutputs: &SafeOutputsConfig{ + Jobs: map[string]*SafeJobConfig{ + "packaging": { + Needs: []string{"packaging"}, + Steps: []any{map[string]any{"run": "echo hi"}}, + }, + }, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateSafeJobNeeds(tt.data) + if tt.wantErr { + require.Error(t, err, "expected validation error") + if tt.errContains != "" { + assert.Contains(t, err.Error(), tt.errContains, + "error should contain expected substring") + } + } else { + assert.NoError(t, err, "expected no validation error") + } + }) + } +} + +// TestValidateSafeJobNeeds_NeedsNormalization verifies that dash-form needs values are +// rewritten to the canonical underscore form so the compiled YAML references the correct job ID. +func TestValidateSafeJobNeeds_NeedsNormalization(t *testing.T) { + jobCfg := &SafeJobConfig{ + Needs: []string{"safe-outputs", "first-job"}, // dash forms + Steps: []any{map[string]any{"run": "echo hi"}}, + } + data := &WorkflowData{ + SafeOutputs: &SafeOutputsConfig{ + CreateIssues: &CreateIssuesConfig{}, + Jobs: map[string]*SafeJobConfig{ + "first-job": {Steps: []any{map[string]any{"run": "echo hi"}}}, + "second-job": jobCfg, + }, + }, + } + + require.NoError(t, validateSafeJobNeeds(data), "should pass validation") + + // After validation the needs entries must be in underscore form + assert.Equal(t, []string{"safe_outputs", "first_job"}, jobCfg.Needs, + "needs entries should be normalized to underscore form") +} + +// TestDetectSafeJobCycles tests cycle detection between custom safe-jobs. +func TestDetectSafeJobCycles(t *testing.T) { + t.Run("no cycles – linear chain", func(t *testing.T) { + jobs := map[string]*SafeJobConfig{ + "job_a": {Needs: []string{"job_b"}}, + "job_b": {}, + } + require.NoError(t, detectSafeJobCycles(jobs)) + }) + + t.Run("no cycles – diamond", func(t *testing.T) { + jobs := map[string]*SafeJobConfig{ + "job_a": {Needs: []string{"job_b", "job_c"}}, + "job_b": {Needs: []string{"job_d"}}, + "job_c": {Needs: []string{"job_d"}}, + "job_d": {}, + } + require.NoError(t, detectSafeJobCycles(jobs)) + }) + + t.Run("direct cycle A→B→A", func(t *testing.T) { + jobs := map[string]*SafeJobConfig{ + "job_a": {Needs: []string{"job_b"}}, + "job_b": {Needs: []string{"job_a"}}, + } + err := detectSafeJobCycles(jobs) + require.Error(t, err, "expected cycle error") + assert.Contains(t, err.Error(), "cycle detected", "error should mention cycle") + }) + + t.Run("three-node cycle", func(t *testing.T) { + jobs := map[string]*SafeJobConfig{ + "job_a": {Needs: []string{"job_b"}}, + "job_b": {Needs: []string{"job_c"}}, + "job_c": {Needs: []string{"job_a"}}, + } + err := detectSafeJobCycles(jobs) + require.Error(t, err, "expected cycle error") + assert.Contains(t, err.Error(), "cycle detected", "error should mention cycle") + }) + + t.Run("empty jobs – no error", func(t *testing.T) { + require.NoError(t, detectSafeJobCycles(nil)) + require.NoError(t, detectSafeJobCycles(map[string]*SafeJobConfig{})) + }) +} + +// TestComputeValidSafeJobNeeds verifies the set of valid job IDs for different configurations. +func TestComputeValidSafeJobNeeds(t *testing.T) { + t.Run("base – no safe-outputs", func(t *testing.T) { + data := &WorkflowData{} + valid := computeValidSafeJobNeeds(data) + assert.True(t, valid["agent"], "agent should always be valid") + assert.False(t, valid["detection"], "detection not valid without safe-outputs") + assert.False(t, valid["safe_outputs"], "safe_outputs not valid without safe-outputs") + }) + + t.Run("only custom jobs configured – safe_outputs absent", func(t *testing.T) { + // When no builtin types / scripts / actions are configured, the consolidated + // safe_outputs job is never emitted, so it must not be a valid target. + data := &WorkflowData{ + SafeOutputs: &SafeOutputsConfig{ + Jobs: map[string]*SafeJobConfig{ + "my-job": {}, + }, + }, + } + valid := computeValidSafeJobNeeds(data) + assert.False(t, valid["safe_outputs"], "safe_outputs should not be valid when only custom jobs present") + }) + + t.Run("builtin type configured – safe_outputs present", func(t *testing.T) { + data := &WorkflowData{ + SafeOutputs: &SafeOutputsConfig{ + ThreatDetection: &ThreatDetectionConfig{}, + CreateIssues: &CreateIssuesConfig{}, + }, + } + valid := computeValidSafeJobNeeds(data) + assert.True(t, valid["agent"]) + assert.True(t, valid["safe_outputs"]) + assert.True(t, valid["detection"], "detection enabled when ThreatDetection is non-nil") + assert.False(t, valid["upload_assets"]) + assert.False(t, valid["unlock"]) + }) + + t.Run("with upload-asset configured", func(t *testing.T) { + data := &WorkflowData{ + SafeOutputs: &SafeOutputsConfig{UploadAssets: &UploadAssetsConfig{}}, + } + valid := computeValidSafeJobNeeds(data) + assert.True(t, valid["upload_assets"]) + }) + + t.Run("with lock-for-agent enabled", func(t *testing.T) { + data := &WorkflowData{ + LockForAgent: true, + SafeOutputs: &SafeOutputsConfig{}, + } + valid := computeValidSafeJobNeeds(data) + assert.True(t, valid["unlock"]) + }) + + t.Run("custom safe-job names are included", func(t *testing.T) { + data := &WorkflowData{ + SafeOutputs: &SafeOutputsConfig{ + Jobs: map[string]*SafeJobConfig{ + "my-packager": {}, + "notify_team": {}, + }, + }, + } + valid := computeValidSafeJobNeeds(data) + assert.True(t, valid["my_packager"], "dash-to-underscore normalized name should be valid") + assert.True(t, valid["notify_team"]) + }) +} + +// TestConsolidatedSafeOutputsJobWillExist verifies the helper correctly predicts +// whether the safe_outputs consolidated job will be emitted. +func TestConsolidatedSafeOutputsJobWillExist(t *testing.T) { + t.Run("nil config", func(t *testing.T) { + assert.False(t, consolidatedSafeOutputsJobWillExist(nil)) + }) + + t.Run("only custom jobs – no consolidated job", func(t *testing.T) { + cfg := &SafeOutputsConfig{ + Jobs: map[string]*SafeJobConfig{"my-job": {}}, + } + assert.False(t, consolidatedSafeOutputsJobWillExist(cfg)) + }) + + t.Run("custom scripts – consolidated job exists", func(t *testing.T) { + cfg := &SafeOutputsConfig{ + Scripts: map[string]*SafeScriptConfig{"my-script": {}}, + } + assert.True(t, consolidatedSafeOutputsJobWillExist(cfg)) + }) + + t.Run("user-provided steps – consolidated job exists", func(t *testing.T) { + cfg := &SafeOutputsConfig{ + Steps: []any{map[string]any{"run": "echo hi"}}, + } + assert.True(t, consolidatedSafeOutputsJobWillExist(cfg)) + }) + + t.Run("builtin type (create-issue) – consolidated job exists", func(t *testing.T) { + cfg := &SafeOutputsConfig{ + CreateIssues: &CreateIssuesConfig{}, + } + assert.True(t, consolidatedSafeOutputsJobWillExist(cfg)) + }) + + t.Run("builtin type + custom jobs – consolidated job exists", func(t *testing.T) { + cfg := &SafeOutputsConfig{ + CreateIssues: &CreateIssuesConfig{}, + Jobs: map[string]*SafeJobConfig{"my-job": {}}, + } + assert.True(t, consolidatedSafeOutputsJobWillExist(cfg)) + }) +}