From b302023fb988e57d178968188bd83f8beb35de52 Mon Sep 17 00:00:00 2001 From: Don Syme Date: Mon, 15 Sep 2025 14:02:14 +0100 Subject: [PATCH 1/3] safe outputs env settings --- pkg/workflow/compiler.go | 40 +++++ pkg/workflow/output_labels.go | 3 + pkg/workflow/output_missing_tool.go | 3 + pkg/workflow/output_push_to_branch.go | 3 + pkg/workflow/output_update_issue.go | 3 + pkg/workflow/safe_outputs_env_test.go | 205 ++++++++++++++++++++++++++ 6 files changed, 257 insertions(+) create mode 100644 pkg/workflow/safe_outputs_env_test.go diff --git a/pkg/workflow/compiler.go b/pkg/workflow/compiler.go index b663562e7e1..fc3562facd0 100644 --- a/pkg/workflow/compiler.go +++ b/pkg/workflow/compiler.go @@ -165,6 +165,7 @@ type SafeOutputsConfig struct { MissingTool *MissingToolConfig `yaml:"missing-tool,omitempty"` // Optional for reporting missing functionality AllowedDomains []string `yaml:"allowed-domains,omitempty"` Staged *bool `yaml:"staged,omitempty"` // If true, emit step summary messages instead of making GitHub API calls + Env map[string]string `yaml:"env,omitempty"` // Environment variables to pass to safe output jobs } // CreateIssuesConfig holds configuration for creating GitHub issues from agent output @@ -2194,6 +2195,9 @@ func (c *Compiler) buildCreateOutputIssueJob(data *WorkflowData, mainJobName str steps = append(steps, " GITHUB_AW_SAFE_OUTPUTS_STAGED: \"true\"\n") } + // Add custom environment variables from safe-outputs.env + c.addCustomSafeOutputEnvVars(&steps, data) + steps = append(steps, " with:\n") steps = append(steps, " script: |\n") @@ -2271,6 +2275,9 @@ func (c *Compiler) buildCreateOutputDiscussionJob(data *WorkflowData, mainJobNam steps = append(steps, " GITHUB_AW_SAFE_OUTPUTS_STAGED: \"true\"\n") } + // Add custom environment variables from safe-outputs.env + c.addCustomSafeOutputEnvVars(&steps, data) + steps = append(steps, " with:\n") steps = append(steps, " script: |\n") @@ -2328,6 +2335,9 @@ func (c *Compiler) buildCreateOutputAddIssueCommentJob(data *WorkflowData, mainJ steps = append(steps, fmt.Sprintf(" GITHUB_AW_COMMENT_TARGET: %q\n", data.SafeOutputs.AddIssueComments.Target)) } + // Add custom environment variables from safe-outputs.env + c.addCustomSafeOutputEnvVars(&steps, data) + steps = append(steps, " with:\n") steps = append(steps, " script: |\n") @@ -2405,6 +2415,9 @@ func (c *Compiler) buildCreateOutputPullRequestReviewCommentJob(data *WorkflowDa steps = append(steps, fmt.Sprintf(" GITHUB_AW_PR_REVIEW_COMMENT_SIDE: %q\n", data.SafeOutputs.CreatePullRequestReviewComments.Side)) } + // Add custom environment variables from safe-outputs.env + c.addCustomSafeOutputEnvVars(&steps, data) + steps = append(steps, " with:\n") steps = append(steps, " script: |\n") @@ -2481,6 +2494,9 @@ func (c *Compiler) buildCreateOutputCodeScanningAlertJob(data *WorkflowData, mai // Pass the workflow filename for rule ID prefix steps = append(steps, fmt.Sprintf(" GITHUB_AW_WORKFLOW_FILENAME: %s\n", workflowFilename)) + // Add custom environment variables from safe-outputs.env + c.addCustomSafeOutputEnvVars(&steps, data) + steps = append(steps, " with:\n") steps = append(steps, " script: |\n") @@ -2601,6 +2617,9 @@ func (c *Compiler) buildCreateOutputPullRequestJob(data *WorkflowData, mainJobNa steps = append(steps, " GITHUB_AW_SAFE_OUTPUTS_STAGED: \"true\"\n") } + // Add custom environment variables from safe-outputs.env + c.addCustomSafeOutputEnvVars(&steps, data) + steps = append(steps, " with:\n") steps = append(steps, " script: |\n") @@ -3706,12 +3725,33 @@ func (c *Compiler) extractSafeOutputsConfig(frontmatter map[string]any) *SafeOut config.Staged = &stagedBool } } + + // Handle env configuration + if env, exists := outputMap["env"]; exists { + if envMap, ok := env.(map[string]any); ok { + config.Env = make(map[string]string) + for key, value := range envMap { + if valueStr, ok := value.(string); ok { + config.Env[key] = valueStr + } + } + } + } } } return config } +// addCustomSafeOutputEnvVars adds custom environment variables to safe output job steps +func (c *Compiler) addCustomSafeOutputEnvVars(steps *[]string, data *WorkflowData) { + if data.SafeOutputs != nil && len(data.SafeOutputs.Env) > 0 { + for key, value := range data.SafeOutputs.Env { + *steps = append(*steps, fmt.Sprintf(" %s: %s\n", key, value)) + } + } +} + // parseIssuesConfig handles create-issue configuration func (c *Compiler) parseIssuesConfig(outputMap map[string]any) *CreateIssuesConfig { if configData, exists := outputMap["create-issue"]; exists { diff --git a/pkg/workflow/output_labels.go b/pkg/workflow/output_labels.go index 819dad2fee5..63883b053a9 100644 --- a/pkg/workflow/output_labels.go +++ b/pkg/workflow/output_labels.go @@ -42,6 +42,9 @@ func (c *Compiler) buildCreateOutputLabelJob(data *WorkflowData, mainJobName str steps = append(steps, " GITHUB_AW_SAFE_OUTPUTS_STAGED: \"true\"\n") } + // Add custom environment variables from safe-outputs.env + c.addCustomSafeOutputEnvVars(&steps, data) + steps = append(steps, " with:\n") steps = append(steps, " script: |\n") diff --git a/pkg/workflow/output_missing_tool.go b/pkg/workflow/output_missing_tool.go index 48eeb0a9893..3700e468f43 100644 --- a/pkg/workflow/output_missing_tool.go +++ b/pkg/workflow/output_missing_tool.go @@ -25,6 +25,9 @@ func (c *Compiler) buildCreateOutputMissingToolJob(data *WorkflowData, mainJobNa steps = append(steps, fmt.Sprintf(" GITHUB_AW_MISSING_TOOL_MAX: %d\n", data.SafeOutputs.MissingTool.Max)) } + // Add custom environment variables from safe-outputs.env + c.addCustomSafeOutputEnvVars(&steps, data) + steps = append(steps, " with:\n") steps = append(steps, " script: |\n") diff --git a/pkg/workflow/output_push_to_branch.go b/pkg/workflow/output_push_to_branch.go index 2c8da9f138e..4d748e95378 100644 --- a/pkg/workflow/output_push_to_branch.go +++ b/pkg/workflow/output_push_to_branch.go @@ -47,6 +47,9 @@ func (c *Compiler) buildCreateOutputPushToPullRequestBranchJob(data *WorkflowDat // Pass the if-no-changes configuration steps = append(steps, fmt.Sprintf(" GITHUB_AW_PUSH_IF_NO_CHANGES: %q\n", data.SafeOutputs.PushToPullRequestBranch.IfNoChanges)) + // Add custom environment variables from safe-outputs.env + c.addCustomSafeOutputEnvVars(&steps, data) + steps = append(steps, " with:\n") steps = append(steps, " script: |\n") diff --git a/pkg/workflow/output_update_issue.go b/pkg/workflow/output_update_issue.go index 7bf61fccd50..b255a1beeb8 100644 --- a/pkg/workflow/output_update_issue.go +++ b/pkg/workflow/output_update_issue.go @@ -30,6 +30,9 @@ func (c *Compiler) buildCreateOutputUpdateIssueJob(data *WorkflowData, mainJobNa steps = append(steps, fmt.Sprintf(" GITHUB_AW_UPDATE_TARGET: %q\n", data.SafeOutputs.UpdateIssues.Target)) } + // Add custom environment variables from safe-outputs.env + c.addCustomSafeOutputEnvVars(&steps, data) + steps = append(steps, " with:\n") steps = append(steps, " script: |\n") diff --git a/pkg/workflow/safe_outputs_env_test.go b/pkg/workflow/safe_outputs_env_test.go new file mode 100644 index 00000000000..81dc26a6ea0 --- /dev/null +++ b/pkg/workflow/safe_outputs_env_test.go @@ -0,0 +1,205 @@ +package workflow + +import ( + "strings" + "testing" +) + +func TestSafeOutputsEnvConfiguration(t *testing.T) { + compiler := NewCompiler(false, "", "test") + + t.Run("Should parse env configuration in safe-outputs", func(t *testing.T) { + frontmatter := map[string]any{ + "name": "Test Workflow", + "safe-outputs": map[string]any{ + "create-issue": nil, + "env": map[string]any{ + "GITHUB_TOKEN": "${{ secrets.DSYME_PAT_FOR_AGENTIC_WORKFLOWS }}", + "CUSTOM_API_KEY": "${{ secrets.CUSTOM_API_KEY }}", + "DEBUG_MODE": "true", + }, + }, + } + + config := compiler.extractSafeOutputsConfig(frontmatter) + if config == nil { + t.Fatal("Expected SafeOutputsConfig to be parsed") + } + + if config.Env == nil { + t.Fatal("Expected Env to be parsed") + } + + expected := map[string]string{ + "GITHUB_TOKEN": "${{ secrets.DSYME_PAT_FOR_AGENTIC_WORKFLOWS }}", + "CUSTOM_API_KEY": "${{ secrets.CUSTOM_API_KEY }}", + "DEBUG_MODE": "true", + } + + for key, expectedValue := range expected { + if actualValue, exists := config.Env[key]; !exists { + t.Errorf("Expected env key %s to exist", key) + } else if actualValue != expectedValue { + t.Errorf("Expected env[%s] to be %q, got %q", key, expectedValue, actualValue) + } + } + }) + + t.Run("Should include custom env vars in create-issue job", func(t *testing.T) { + data := &WorkflowData{ + Name: "Test", + FrontmatterName: "Test Workflow", + SafeOutputs: &SafeOutputsConfig{ + CreateIssues: &CreateIssuesConfig{Max: 1}, + Env: map[string]string{ + "GITHUB_TOKEN": "${{ secrets.DSYME_PAT_FOR_AGENTIC_WORKFLOWS }}", + "DEBUG_MODE": "true", + }, + }, + } + + job, err := compiler.buildCreateOutputIssueJob(data, "main_job", false, nil) + if err != nil { + t.Fatalf("Failed to build create issue job: %v", err) + } + + // Check that the steps include our custom environment variables + stepsStr := strings.Join(job.Steps, "") + + if !strings.Contains(stepsStr, "GITHUB_TOKEN: ${{ secrets.DSYME_PAT_FOR_AGENTIC_WORKFLOWS }}") { + t.Error("Expected GITHUB_TOKEN to be included in job steps") + } + + if !strings.Contains(stepsStr, "DEBUG_MODE: true") { + t.Error("Expected DEBUG_MODE to be included in job steps") + } + }) + + t.Run("Should include custom env vars in create-pull-request job", func(t *testing.T) { + data := &WorkflowData{ + Name: "Test", + FrontmatterName: "Test Workflow", + SafeOutputs: &SafeOutputsConfig{ + CreatePullRequests: &CreatePullRequestsConfig{Max: 1}, + Env: map[string]string{ + "GITHUB_TOKEN": "${{ secrets.DSYME_PAT_FOR_AGENTIC_WORKFLOWS }}", + "API_ENDPOINT": "https://api.example.com", + }, + }, + } + + job, err := compiler.buildCreateOutputPullRequestJob(data, "main_job") + if err != nil { + t.Fatalf("Failed to build create pull request job: %v", err) + } + + // Check that the steps include our custom environment variables + stepsStr := strings.Join(job.Steps, "") + + if !strings.Contains(stepsStr, "GITHUB_TOKEN: ${{ secrets.DSYME_PAT_FOR_AGENTIC_WORKFLOWS }}") { + t.Error("Expected GITHUB_TOKEN to be included in job steps") + } + + if !strings.Contains(stepsStr, "API_ENDPOINT: https://api.example.com") { + t.Error("Expected API_ENDPOINT to be included in job steps") + } + }) + + t.Run("Should work without env configuration", func(t *testing.T) { + frontmatter := map[string]any{ + "name": "Test Workflow", + "safe-outputs": map[string]any{ + "create-issue": nil, + }, + } + + config := compiler.extractSafeOutputsConfig(frontmatter) + if config == nil { + t.Fatal("Expected SafeOutputsConfig to be parsed") + } + + // Env should be nil when not specified + if config.Env != nil { + t.Error("Expected Env to be nil when not configured") + } + + // Job creation should still work + data := &WorkflowData{ + Name: "Test", + FrontmatterName: "Test Workflow", + SafeOutputs: config, + } + + _, err := compiler.buildCreateOutputIssueJob(data, "main_job", false, nil) + if err != nil { + t.Errorf("Job creation should work without env configuration: %v", err) + } + }) + + t.Run("Should handle empty env configuration", func(t *testing.T) { + frontmatter := map[string]any{ + "name": "Test Workflow", + "safe-outputs": map[string]any{ + "create-issue": nil, + "env": map[string]any{}, + }, + } + + config := compiler.extractSafeOutputsConfig(frontmatter) + if config == nil { + t.Fatal("Expected SafeOutputsConfig to be parsed") + } + + if config.Env == nil { + t.Error("Expected Env to be empty map, not nil") + } + + if len(config.Env) != 0 { + t.Errorf("Expected Env to be empty, got %d entries", len(config.Env)) + } + }) + + t.Run("Should handle non-string env values gracefully", func(t *testing.T) { + frontmatter := map[string]any{ + "name": "Test Workflow", + "safe-outputs": map[string]any{ + "create-issue": nil, + "env": map[string]any{ + "STRING_VALUE": "valid", + "INT_VALUE": 123, // should be ignored + "BOOL_VALUE": true, // should be ignored + "NULL_VALUE": nil, // should be ignored + }, + }, + } + + config := compiler.extractSafeOutputsConfig(frontmatter) + if config == nil { + t.Fatal("Expected SafeOutputsConfig to be parsed") + } + + if config.Env == nil { + t.Fatal("Expected Env to be parsed") + } + + // Only string values should be included + if len(config.Env) != 1 { + t.Errorf("Expected only 1 env var (string values only), got %d", len(config.Env)) + } + + if config.Env["STRING_VALUE"] != "valid" { + t.Error("Expected STRING_VALUE to be preserved") + } + + // Non-string values should be ignored + if _, exists := config.Env["INT_VALUE"]; exists { + t.Error("Expected INT_VALUE to be ignored") + } + if _, exists := config.Env["BOOL_VALUE"]; exists { + t.Error("Expected BOOL_VALUE to be ignored") + } + if _, exists := config.Env["NULL_VALUE"]; exists { + t.Error("Expected NULL_VALUE to be ignored") + } + }) +} From ab40594f5f397d00372d2e876f94318c0dc76920 Mon Sep 17 00:00:00 2001 From: Don Syme Date: Mon, 15 Sep 2025 14:30:56 +0100 Subject: [PATCH 2/3] safe outputs env settings --- pkg/parser/schemas/main_workflow_schema.json | 11 +++++++++++ pkg/workflow/safe_outputs_env_test.go | 12 ++++++------ 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index c07f5cd785a..6887f0dc6a2 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -1600,6 +1600,17 @@ "staged": { "type": "boolean", "description": "If true, emit step summary messages instead of making GitHub API calls (preview mode)" + }, + "env": { + "type": "object", + "description": "Environment variables to pass to safe output jobs", + "patternProperties": { + "^[A-Za-z_][A-Za-z0-9_]*$": { + "type": "string", + "description": "Environment variable value, typically a secret reference like ${{ secrets.TOKEN_NAME }}" + } + }, + "additionalProperties": false } }, "additionalProperties": false diff --git a/pkg/workflow/safe_outputs_env_test.go b/pkg/workflow/safe_outputs_env_test.go index 7d662bb7ea6..acb0b914aee 100644 --- a/pkg/workflow/safe_outputs_env_test.go +++ b/pkg/workflow/safe_outputs_env_test.go @@ -14,7 +14,7 @@ func TestSafeOutputsEnvConfiguration(t *testing.T) { "safe-outputs": map[string]any{ "create-issue": nil, "env": map[string]any{ - "GITHUB_TOKEN": "${{ secrets.DSYME_PAT_FOR_AGENTIC_WORKFLOWS }}", + "GITHUB_TOKEN": "${{ secrets.SOME_PAT_FOR_AGENTIC_WORKFLOWS }}", "CUSTOM_API_KEY": "${{ secrets.CUSTOM_API_KEY }}", "DEBUG_MODE": "true", }, @@ -31,7 +31,7 @@ func TestSafeOutputsEnvConfiguration(t *testing.T) { } expected := map[string]string{ - "GITHUB_TOKEN": "${{ secrets.DSYME_PAT_FOR_AGENTIC_WORKFLOWS }}", + "GITHUB_TOKEN": "${{ secrets.SOME_PAT_FOR_AGENTIC_WORKFLOWS }}", "CUSTOM_API_KEY": "${{ secrets.CUSTOM_API_KEY }}", "DEBUG_MODE": "true", } @@ -52,7 +52,7 @@ func TestSafeOutputsEnvConfiguration(t *testing.T) { SafeOutputs: &SafeOutputsConfig{ CreateIssues: &CreateIssuesConfig{Max: 1}, Env: map[string]string{ - "GITHUB_TOKEN": "${{ secrets.DSYME_PAT_FOR_AGENTIC_WORKFLOWS }}", + "GITHUB_TOKEN": "${{ secrets.SOME_PAT_FOR_AGENTIC_WORKFLOWS }}", "DEBUG_MODE": "true", }, }, @@ -66,7 +66,7 @@ func TestSafeOutputsEnvConfiguration(t *testing.T) { // Check that the steps include our custom environment variables stepsStr := strings.Join(job.Steps, "") - if !strings.Contains(stepsStr, "GITHUB_TOKEN: ${{ secrets.DSYME_PAT_FOR_AGENTIC_WORKFLOWS }}") { + if !strings.Contains(stepsStr, "GITHUB_TOKEN: ${{ secrets.SOME_PAT_FOR_AGENTIC_WORKFLOWS }}") { t.Error("Expected GITHUB_TOKEN to be included in job steps") } @@ -82,7 +82,7 @@ func TestSafeOutputsEnvConfiguration(t *testing.T) { SafeOutputs: &SafeOutputsConfig{ CreatePullRequests: &CreatePullRequestsConfig{Max: 1}, Env: map[string]string{ - "GITHUB_TOKEN": "${{ secrets.DSYME_PAT_FOR_AGENTIC_WORKFLOWS }}", + "GITHUB_TOKEN": "${{ secrets.SOME_PAT_FOR_AGENTIC_WORKFLOWS }}", "API_ENDPOINT": "https://api.example.com", }, }, @@ -96,7 +96,7 @@ func TestSafeOutputsEnvConfiguration(t *testing.T) { // Check that the steps include our custom environment variables stepsStr := strings.Join(job.Steps, "") - if !strings.Contains(stepsStr, "GITHUB_TOKEN: ${{ secrets.DSYME_PAT_FOR_AGENTIC_WORKFLOWS }}") { + if !strings.Contains(stepsStr, "GITHUB_TOKEN: ${{ secrets.SOME_PAT_FOR_AGENTIC_WORKFLOWS }}") { t.Error("Expected GITHUB_TOKEN to be included in job steps") } From ebb6cff75cf59177ed1ed38d77a2edb5f9fc80f7 Mon Sep 17 00:00:00 2001 From: Don Syme Date: Mon, 15 Sep 2025 14:50:31 +0100 Subject: [PATCH 3/3] safe outputs github-token setting --- .../content/docs/reference/safe-outputs.md | 21 ++ pkg/cli/templates/instructions.md | 12 +- pkg/parser/schemas/main_workflow_schema.json | 4 + pkg/workflow/compiler.go | 31 +- pkg/workflow/output_labels.go | 2 + pkg/workflow/output_missing_tool.go | 2 + pkg/workflow/output_push_to_branch.go | 2 + pkg/workflow/output_update_issue.go | 2 + .../safe_outputs_env_integration_test.go | 318 ++++++++++++++++++ .../safe_outputs_github_token_test.go | 254 ++++++++++++++ 10 files changed, 645 insertions(+), 3 deletions(-) create mode 100644 pkg/workflow/safe_outputs_env_integration_test.go create mode 100644 pkg/workflow/safe_outputs_github_token_test.go diff --git a/docs/src/content/docs/reference/safe-outputs.md b/docs/src/content/docs/reference/safe-outputs.md index 5ec7952143d..14c733cf01d 100644 --- a/docs/src/content/docs/reference/safe-outputs.md +++ b/docs/src/content/docs/reference/safe-outputs.md @@ -551,8 +551,29 @@ safe-outputs: - github.com # Default GitHub domains are always included - api.github.com # Additional trusted domains can be specified - trusted-domain.com # URIs from unlisted domains are replaced with "(redacted)" + github-token: ${{ secrets.CUSTOM_PAT }} # Optional: custom GitHub token for safe output jobs ``` +## Global Configuration Options + +### Custom GitHub Token (`github-token:`) + +By default, safe output jobs use the standard `GITHUB_TOKEN` provided by GitHub Actions. You can specify a custom GitHub token for all safe output jobs: + +```yaml +safe-outputs: + create-issue: + add-issue-comment: + github-token: ${{ secrets.CUSTOM_PAT }} # Use custom PAT instead of GITHUB_TOKEN +``` + +This is useful when: +- You need additional permissions beyond what `GITHUB_TOKEN` provides +- You want to perform actions across multiple repositories +- You need to bypass GitHub Actions token restrictions + +**Note:** The custom `github-token` is applied to all safe output jobs (create-issue, add-issue-comment, create-pull-request, etc.). Individual safe output types cannot have different tokens. + ## Related Documentation - [Frontmatter Options](frontmatter.md) - All configuration options for workflows diff --git a/pkg/cli/templates/instructions.md b/pkg/cli/templates/instructions.md index 3a8aba1d4b5..66f4a021866 100644 --- a/pkg/cli/templates/instructions.md +++ b/pkg/cli/templates/instructions.md @@ -212,7 +212,17 @@ The YAML frontmatter supports these fields: body: true # Optional: allow updating issue body max: 3 # Optional: maximum number of issues to update (default: 1) ``` - When using `safe-outputs.update-issue`, the main job does **not** need `issues: write` permission since issue updates are handled by a separate job with appropriate permissions. + When using `safe-outputs.update-issue`, the main job does **not** need `issues: write` permission since issue updates are handled by a separate job with appropriate permissions. + + **Global Safe Output Configuration:** + - `github-token:` - Custom GitHub token for all safe output jobs + ```yaml + safe-outputs: + create-issue: + add-issue-comment: + github-token: ${{ secrets.CUSTOM_PAT }} # Use custom PAT instead of GITHUB_TOKEN + ``` + Useful when you need additional permissions or want to perform actions across repositories. - **`alias:`** - Alternative workflow name (string) - **`cache:`** - Cache configuration for workflow dependencies (object or array) diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 6887f0dc6a2..cf8df758959 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -1611,6 +1611,10 @@ } }, "additionalProperties": false + }, + "github-token": { + "type": "string", + "description": "GitHub token to use for safe output jobs. Typically a secret reference like ${{ secrets.GITHUB_TOKEN }} or ${{ secrets.CUSTOM_PAT }}" } }, "additionalProperties": false diff --git a/pkg/workflow/compiler.go b/pkg/workflow/compiler.go index c9cf5380872..7b990f37e7e 100644 --- a/pkg/workflow/compiler.go +++ b/pkg/workflow/compiler.go @@ -173,8 +173,9 @@ type SafeOutputsConfig struct { PushToPullRequestBranch *PushToPullRequestBranchConfig `yaml:"push-to-pr-branch,omitempty"` MissingTool *MissingToolConfig `yaml:"missing-tool,omitempty"` // Optional for reporting missing functionality AllowedDomains []string `yaml:"allowed-domains,omitempty"` - Staged *bool `yaml:"staged,omitempty"` // If true, emit step summary messages instead of making GitHub API calls - Env map[string]string `yaml:"env,omitempty"` // Environment variables to pass to safe output jobs + Staged *bool `yaml:"staged,omitempty"` // If true, emit step summary messages instead of making GitHub API calls + Env map[string]string `yaml:"env,omitempty"` // Environment variables to pass to safe output jobs + GitHubToken string `yaml:"github-token,omitempty"` // GitHub token for safe output jobs } // CreateIssuesConfig holds configuration for creating GitHub issues from agent output @@ -2209,6 +2210,8 @@ func (c *Compiler) buildCreateOutputIssueJob(data *WorkflowData, mainJobName str c.addCustomSafeOutputEnvVars(&steps, data) steps = append(steps, " with:\n") + // Add github-token if specified + c.addSafeOutputGitHubToken(&steps, data) steps = append(steps, " script: |\n") // Add each line of the script with proper indentation @@ -2289,6 +2292,8 @@ func (c *Compiler) buildCreateOutputDiscussionJob(data *WorkflowData, mainJobNam c.addCustomSafeOutputEnvVars(&steps, data) steps = append(steps, " with:\n") + // Add github-token if specified + c.addSafeOutputGitHubToken(&steps, data) steps = append(steps, " script: |\n") // Add each line of the script with proper indentation @@ -2349,6 +2354,8 @@ func (c *Compiler) buildCreateOutputAddIssueCommentJob(data *WorkflowData, mainJ c.addCustomSafeOutputEnvVars(&steps, data) steps = append(steps, " with:\n") + // Add github-token if specified + c.addSafeOutputGitHubToken(&steps, data) steps = append(steps, " script: |\n") // Add each line of the script with proper indentation @@ -2429,6 +2436,8 @@ func (c *Compiler) buildCreateOutputPullRequestReviewCommentJob(data *WorkflowDa c.addCustomSafeOutputEnvVars(&steps, data) steps = append(steps, " with:\n") + // Add github-token if specified + c.addSafeOutputGitHubToken(&steps, data) steps = append(steps, " script: |\n") // Add each line of the script with proper indentation @@ -2508,6 +2517,8 @@ func (c *Compiler) buildCreateOutputCodeScanningAlertJob(data *WorkflowData, mai c.addCustomSafeOutputEnvVars(&steps, data) steps = append(steps, " with:\n") + // Add github-token if specified + c.addSafeOutputGitHubToken(&steps, data) steps = append(steps, " script: |\n") // Add each line of the script with proper indentation @@ -2631,6 +2642,8 @@ func (c *Compiler) buildCreateOutputPullRequestJob(data *WorkflowData, mainJobNa c.addCustomSafeOutputEnvVars(&steps, data) steps = append(steps, " with:\n") + // Add github-token if specified + c.addSafeOutputGitHubToken(&steps, data) steps = append(steps, " script: |\n") // Add each line of the script with proper indentation @@ -3750,6 +3763,13 @@ func (c *Compiler) extractSafeOutputsConfig(frontmatter map[string]any) *SafeOut } } } + + // Handle github-token configuration + if githubToken, exists := outputMap["github-token"]; exists { + if githubTokenStr, ok := githubToken.(string); ok { + config.GitHubToken = githubTokenStr + } + } } } @@ -3765,6 +3785,13 @@ func (c *Compiler) addCustomSafeOutputEnvVars(steps *[]string, data *WorkflowDat } } +// addSafeOutputGitHubToken adds github-token to the with section of github-script actions +func (c *Compiler) addSafeOutputGitHubToken(steps *[]string, data *WorkflowData) { + if data.SafeOutputs != nil && data.SafeOutputs.GitHubToken != "" { + *steps = append(*steps, fmt.Sprintf(" github-token: %s\n", data.SafeOutputs.GitHubToken)) + } +} + // extractCacheMemoryConfig extracts cache-memory configuration from tools section func (c *Compiler) extractCacheMemoryConfig(tools map[string]any) *CacheMemoryConfig { cacheMemoryValue, exists := tools["cache-memory"] diff --git a/pkg/workflow/output_labels.go b/pkg/workflow/output_labels.go index 63883b053a9..19c6fb564f4 100644 --- a/pkg/workflow/output_labels.go +++ b/pkg/workflow/output_labels.go @@ -46,6 +46,8 @@ func (c *Compiler) buildCreateOutputLabelJob(data *WorkflowData, mainJobName str c.addCustomSafeOutputEnvVars(&steps, data) steps = append(steps, " with:\n") + // Add github-token if specified + c.addSafeOutputGitHubToken(&steps, data) steps = append(steps, " script: |\n") // Add each line of the script with proper indentation diff --git a/pkg/workflow/output_missing_tool.go b/pkg/workflow/output_missing_tool.go index 3700e468f43..237973562e8 100644 --- a/pkg/workflow/output_missing_tool.go +++ b/pkg/workflow/output_missing_tool.go @@ -29,6 +29,8 @@ func (c *Compiler) buildCreateOutputMissingToolJob(data *WorkflowData, mainJobNa c.addCustomSafeOutputEnvVars(&steps, data) steps = append(steps, " with:\n") + // Add github-token if specified + c.addSafeOutputGitHubToken(&steps, data) steps = append(steps, " script: |\n") // Add each line of the script with proper indentation diff --git a/pkg/workflow/output_push_to_branch.go b/pkg/workflow/output_push_to_branch.go index 4d748e95378..6e9340b78e5 100644 --- a/pkg/workflow/output_push_to_branch.go +++ b/pkg/workflow/output_push_to_branch.go @@ -51,6 +51,8 @@ func (c *Compiler) buildCreateOutputPushToPullRequestBranchJob(data *WorkflowDat c.addCustomSafeOutputEnvVars(&steps, data) steps = append(steps, " with:\n") + // Add github-token if specified + c.addSafeOutputGitHubToken(&steps, data) steps = append(steps, " script: |\n") // Add each line of the script with proper indentation diff --git a/pkg/workflow/output_update_issue.go b/pkg/workflow/output_update_issue.go index b255a1beeb8..25b346895a1 100644 --- a/pkg/workflow/output_update_issue.go +++ b/pkg/workflow/output_update_issue.go @@ -34,6 +34,8 @@ func (c *Compiler) buildCreateOutputUpdateIssueJob(data *WorkflowData, mainJobNa c.addCustomSafeOutputEnvVars(&steps, data) steps = append(steps, " with:\n") + // Add github-token if specified + c.addSafeOutputGitHubToken(&steps, data) steps = append(steps, " script: |\n") // Add each line of the script with proper indentation diff --git a/pkg/workflow/safe_outputs_env_integration_test.go b/pkg/workflow/safe_outputs_env_integration_test.go new file mode 100644 index 00000000000..14c03b4cc5e --- /dev/null +++ b/pkg/workflow/safe_outputs_env_integration_test.go @@ -0,0 +1,318 @@ +package workflow + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestSafeOutputsEnvIntegration(t *testing.T) { + tests := []struct { + name string + frontmatter map[string]any + expectedEnvVars []string + expectedSafeOutput string + }{ + { + name: "Create issue job with custom env vars", + frontmatter: map[string]any{ + "name": "Test Workflow", + "on": "push", + "safe-outputs": map[string]any{ + "create-issue": nil, + "env": map[string]any{ + "GITHUB_TOKEN": "${{ secrets.SOME_PAT_FOR_AGENTIC_WORKFLOWS }}", + "DEBUG_MODE": "true", + }, + }, + }, + expectedEnvVars: []string{ + "GITHUB_TOKEN: ${{ secrets.SOME_PAT_FOR_AGENTIC_WORKFLOWS }}", + "DEBUG_MODE: true", + }, + expectedSafeOutput: "create-issue", + }, + { + name: "Create pull request job with custom env vars", + frontmatter: map[string]any{ + "name": "Test Workflow", + "on": "push", + "safe-outputs": map[string]any{ + "create-pull-request": nil, + "env": map[string]any{ + "CUSTOM_API_KEY": "${{ secrets.CUSTOM_API_KEY }}", + "ENVIRONMENT": "production", + }, + }, + }, + expectedEnvVars: []string{ + "CUSTOM_API_KEY: ${{ secrets.CUSTOM_API_KEY }}", + "ENVIRONMENT: production", + }, + expectedSafeOutput: "create-pull-request", + }, + { + name: "Add issue comment job with custom env vars", + frontmatter: map[string]any{ + "name": "Test Workflow", + "on": "issues", + "safe-outputs": map[string]any{ + "add-issue-comment": nil, + "env": map[string]any{ + "NOTIFICATION_URL": "${{ secrets.WEBHOOK_URL }}", + "COMMENT_TEMPLATE": "template-v2", + }, + }, + }, + expectedEnvVars: []string{ + "NOTIFICATION_URL: ${{ secrets.WEBHOOK_URL }}", + "COMMENT_TEMPLATE: template-v2", + }, + expectedSafeOutput: "add-issue-comment", + }, + { + name: "Multiple safe outputs with shared env vars", + frontmatter: map[string]any{ + "name": "Test Workflow", + "on": "push", + "safe-outputs": map[string]any{ + "create-issue": nil, + "create-pull-request": nil, + "env": map[string]any{ + "SHARED_TOKEN": "${{ secrets.SHARED_TOKEN }}", + "WORKFLOW_ID": "multi-output-test", + }, + }, + }, + expectedEnvVars: []string{ + "SHARED_TOKEN: ${{ secrets.SHARED_TOKEN }}", + "WORKFLOW_ID: multi-output-test", + }, + expectedSafeOutput: "create-issue,create-pull-request", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + compiler := NewCompiler(false, "", "test") + + // Extract the safe outputs configuration + config := compiler.extractSafeOutputsConfig(tt.frontmatter) + if config == nil { + t.Fatal("Expected SafeOutputsConfig to be parsed") + } + + // Verify env configuration is parsed correctly + if config.Env == nil { + t.Fatal("Expected Env to be parsed") + } + + // Build workflow data + data := &WorkflowData{ + Name: "Test", + FrontmatterName: "Test Workflow", + SafeOutputs: config, + } + + // Test job generation for each safe output type + if strings.Contains(tt.expectedSafeOutput, "create-issue") { + job, err := compiler.buildCreateOutputIssueJob(data, "main_job", false, tt.frontmatter) + if err != nil { + t.Errorf("Error building create issue job: %v", err) + } + + // Verify environment variables are included in job steps + jobYAML := strings.Join(job.Steps, "") + for _, expectedEnvVar := range tt.expectedEnvVars { + if !strings.Contains(jobYAML, expectedEnvVar) { + t.Errorf("Expected env var %q not found in create issue job YAML", expectedEnvVar) + } + } + } + + if strings.Contains(tt.expectedSafeOutput, "create-pull-request") { + job, err := compiler.buildCreateOutputPullRequestJob(data, "main_job") + if err != nil { + t.Errorf("Error building create pull request job: %v", err) + } + + // Verify environment variables are included in job steps + jobYAML := strings.Join(job.Steps, "") + for _, expectedEnvVar := range tt.expectedEnvVars { + if !strings.Contains(jobYAML, expectedEnvVar) { + t.Errorf("Expected env var %q not found in create pull request job YAML", expectedEnvVar) + } + } + } + + if strings.Contains(tt.expectedSafeOutput, "add-issue-comment") { + job, err := compiler.buildCreateOutputAddIssueCommentJob(data, "main_job") + if err != nil { + t.Errorf("Error building add issue comment job: %v", err) + } + + // Verify environment variables are included in job steps + jobYAML := strings.Join(job.Steps, "") + for _, expectedEnvVar := range tt.expectedEnvVars { + if !strings.Contains(jobYAML, expectedEnvVar) { + t.Errorf("Expected env var %q not found in add issue comment job YAML", expectedEnvVar) + } + } + } + }) + } +} + +func TestSafeOutputsEnvFullWorkflowCompilation(t *testing.T) { + // Create a temporary directory for the test + tmpDir := t.TempDir() + + // Create a test workflow file + workflowContent := `--- +name: Test Environment Variables +on: push +safe-outputs: + create-issue: + title-prefix: "[env-test] " + labels: ["automated", "env-test"] + env: + GITHUB_TOKEN: ${{ secrets.SOME_PAT_FOR_AGENTIC_WORKFLOWS }} + DEBUG_MODE: "true" + CUSTOM_API_KEY: ${{ secrets.CUSTOM_API_KEY }} +--- + +# Environment Variables Test Workflow + +This workflow tests that custom environment variables are properly passed through +to safe output jobs. + +Create an issue with test results. +` + + workflowFile := filepath.Join(tmpDir, "test-env-workflow.md") + if err := os.WriteFile(workflowFile, []byte(workflowContent), 0644); err != nil { + t.Fatalf("Failed to write test workflow file: %v", err) + } + + // Parse the workflow data to get the structure (using the same approach as existing tests) + compiler := NewCompiler(false, "", "test") + workflowData, err := compiler.parseWorkflowFile(workflowFile) + if err != nil { + t.Fatalf("Failed to parse workflow: %v", err) + } + + // Verify the SafeOutputs configuration includes our environment variables + if workflowData.SafeOutputs == nil { + t.Fatal("Expected SafeOutputs to be parsed") + } + + if workflowData.SafeOutputs.Env == nil { + t.Fatal("Expected Env to be parsed") + } + + expectedEnvVars := map[string]string{ + "GITHUB_TOKEN": "${{ secrets.SOME_PAT_FOR_AGENTIC_WORKFLOWS }}", + "DEBUG_MODE": "true", + "CUSTOM_API_KEY": "${{ secrets.CUSTOM_API_KEY }}", + } + + for key, expectedValue := range expectedEnvVars { + if actualValue, exists := workflowData.SafeOutputs.Env[key]; !exists { + t.Errorf("Expected env key %s to exist", key) + } else if actualValue != expectedValue { + t.Errorf("Expected env[%s] to be %q, got %q", key, expectedValue, actualValue) + } + } + + // Build the create issue job and verify it includes our environment variables + job, err := compiler.buildCreateOutputIssueJob(workflowData, "main_job", false, nil) + if err != nil { + t.Fatalf("Failed to build create issue job: %v", err) + } + + jobYAML := strings.Join(job.Steps, "") + + for key, expectedValue := range expectedEnvVars { + expectedEnvLine := key + ": " + expectedValue + if !strings.Contains(jobYAML, expectedEnvLine) { + t.Errorf("Expected environment variable %q not found in job YAML", expectedEnvLine) + } + } + + // Verify issue configuration is present + if !strings.Contains(jobYAML, "GITHUB_AW_ISSUE_TITLE_PREFIX: \"[env-test] \"") { + t.Error("Expected issue title prefix not found in job YAML") + } + + if !strings.Contains(jobYAML, "GITHUB_AW_ISSUE_LABELS: \"automated,env-test\"") { + t.Error("Expected issue labels not found in job YAML") + } + + t.Logf("✓ %s", workflowFile) +} + +func TestSafeOutputsEnvWithStagedMode(t *testing.T) { + // Create a temporary directory for the test + tmpDir := t.TempDir() + + // Create a test workflow file with staged mode and env vars + workflowContent := `--- +name: Test Environment Variables with Staged Mode +on: push +safe-outputs: + create-issue: + env: + GITHUB_TOKEN: ${{ secrets.SOME_PAT_FOR_AGENTIC_WORKFLOWS }} + DEBUG_MODE: "true" + staged: true +--- + +# Environment Variables with Staged Mode Test + +This workflow tests that custom environment variables work with staged mode. +` + + workflowFile := filepath.Join(tmpDir, "test-env-staged-workflow.md") + if err := os.WriteFile(workflowFile, []byte(workflowContent), 0644); err != nil { + t.Fatalf("Failed to write test workflow file: %v", err) + } + + // Parse the workflow data + compiler := NewCompiler(false, "", "test") + workflowData, err := compiler.parseWorkflowFile(workflowFile) + if err != nil { + t.Fatalf("Failed to parse workflow: %v", err) + } + + // Verify staged mode is enabled + if workflowData.SafeOutputs.Staged == nil || !*workflowData.SafeOutputs.Staged { + t.Error("Expected staged mode to be enabled") + } + + // Build the create issue job and verify it includes our environment variables and staged flag + job, err := compiler.buildCreateOutputIssueJob(workflowData, "main_job", false, nil) + if err != nil { + t.Fatalf("Failed to build create issue job: %v", err) + } + + jobYAML := strings.Join(job.Steps, "") + + expectedEnvVars := []string{ + "GITHUB_TOKEN: ${{ secrets.SOME_PAT_FOR_AGENTIC_WORKFLOWS }}", + "DEBUG_MODE: true", + } + + for _, expectedEnvVar := range expectedEnvVars { + if !strings.Contains(jobYAML, expectedEnvVar) { + t.Errorf("Expected environment variable %q not found in job YAML", expectedEnvVar) + } + } + + // Verify staged mode is enabled + if !strings.Contains(jobYAML, "GITHUB_AW_SAFE_OUTPUTS_STAGED: \"true\"") { + t.Error("Expected staged mode flag not found in job YAML") + } + + t.Logf("✓ %s", workflowFile) +} diff --git a/pkg/workflow/safe_outputs_github_token_test.go b/pkg/workflow/safe_outputs_github_token_test.go new file mode 100644 index 00000000000..00d1ab85b06 --- /dev/null +++ b/pkg/workflow/safe_outputs_github_token_test.go @@ -0,0 +1,254 @@ +package workflow + +import ( + "strings" + "testing" +) + +func TestSafeOutputsGitHubTokenConfiguration(t *testing.T) { + compiler := NewCompiler(false, "", "test") + + t.Run("Should parse github-token configuration in safe-outputs", func(t *testing.T) { + frontmatter := map[string]any{ + "name": "Test Workflow", + "safe-outputs": map[string]any{ + "create-issue": nil, + "github-token": "${{ secrets.CUSTOM_PAT }}", + }, + } + + config := compiler.extractSafeOutputsConfig(frontmatter) + if config == nil { + t.Fatal("Expected SafeOutputsConfig to be parsed") + } + + if config.GitHubToken != "${{ secrets.CUSTOM_PAT }}" { + t.Errorf("Expected GitHubToken to be '${{ secrets.CUSTOM_PAT }}', got '%s'", config.GitHubToken) + } + }) + + t.Run("Should handle missing github-token field", func(t *testing.T) { + frontmatter := map[string]any{ + "name": "Test Workflow", + "safe-outputs": map[string]any{ + "create-issue": nil, + }, + } + + config := compiler.extractSafeOutputsConfig(frontmatter) + if config == nil { + t.Fatal("Expected SafeOutputsConfig to be parsed") + } + + if config.GitHubToken != "" { + t.Errorf("Expected GitHubToken to be empty, got '%s'", config.GitHubToken) + } + }) + + t.Run("Should handle non-string github-token field", func(t *testing.T) { + frontmatter := map[string]any{ + "name": "Test Workflow", + "safe-outputs": map[string]any{ + "create-issue": nil, + "github-token": 123, // Invalid type + }, + } + + config := compiler.extractSafeOutputsConfig(frontmatter) + if config == nil { + t.Fatal("Expected SafeOutputsConfig to be parsed") + } + + if config.GitHubToken != "" { + t.Errorf("Expected GitHubToken to be empty when non-string, got '%s'", config.GitHubToken) + } + }) +} + +func TestSafeOutputsGitHubTokenIntegration(t *testing.T) { + tests := []struct { + name string + frontmatter map[string]any + expectedInWith []string + unexpectedInWith []string + }{ + { + name: "create-issue with github-token", + frontmatter: map[string]any{ + "name": "Test Workflow", + "safe-outputs": map[string]any{ + "create-issue": nil, + "github-token": "${{ secrets.CUSTOM_PAT }}", + }, + }, + expectedInWith: []string{ + "github-token: ${{ secrets.CUSTOM_PAT }}", + }, + unexpectedInWith: []string{}, + }, + { + name: "create-issue without github-token", + frontmatter: map[string]any{ + "name": "Test Workflow", + "safe-outputs": map[string]any{ + "create-issue": nil, + }, + }, + expectedInWith: []string{}, + unexpectedInWith: []string{"github-token:"}, + }, + { + name: "multiple safe outputs with github-token", + frontmatter: map[string]any{ + "name": "Test Workflow", + "safe-outputs": map[string]any{ + "create-issue": nil, + "add-issue-comment": nil, + "create-pull-request": nil, + "github-token": "${{ secrets.GITHUB_TOKEN }}", + }, + }, + expectedInWith: []string{ + "github-token: ${{ secrets.GITHUB_TOKEN }}", + }, + unexpectedInWith: []string{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + compiler := NewCompiler(false, "", "test") + config := compiler.extractSafeOutputsConfig(tt.frontmatter) + + // Test the config generation in safe output jobs by creating a mock workflow data + workflowData := &WorkflowData{ + Name: "Test Workflow", + SafeOutputs: config, + } + + // Test create-issue job if configured + if config != nil && config.CreateIssues != nil { + job, err := compiler.buildCreateOutputIssueJob(workflowData, "main", false, tt.frontmatter) + if err != nil { + t.Fatalf("Failed to build create issue job: %v", err) + } + + jobYAML := strings.Join(job.Steps, "") + + // Check expected strings are present + for _, expected := range tt.expectedInWith { + if !strings.Contains(jobYAML, expected) { + t.Errorf("Expected '%s' to be present in job YAML, but it was not found", expected) + } + } + + // Check unexpected strings are not present + for _, unexpected := range tt.unexpectedInWith { + if strings.Contains(jobYAML, unexpected) { + t.Errorf("Expected '%s' to NOT be present in job YAML, but it was found", unexpected) + } + } + } + + // Test add-issue-comment job if configured + if config != nil && config.AddIssueComments != nil { + job, err := compiler.buildCreateOutputAddIssueCommentJob(workflowData, "main") + if err != nil { + t.Fatalf("Failed to build add issue comment job: %v", err) + } + + jobYAML := strings.Join(job.Steps, "") + + // Check expected strings are present + for _, expected := range tt.expectedInWith { + if !strings.Contains(jobYAML, expected) { + t.Errorf("Expected '%s' to be present in add comment job YAML, but it was not found", expected) + } + } + + // Check unexpected strings are not present + for _, unexpected := range tt.unexpectedInWith { + if strings.Contains(jobYAML, unexpected) { + t.Errorf("Expected '%s' to NOT be present in add comment job YAML, but it was found", unexpected) + } + } + } + + // Test create-pull-request job if configured + if config != nil && config.CreatePullRequests != nil { + job, err := compiler.buildCreateOutputPullRequestJob(workflowData, "main") + if err != nil { + t.Fatalf("Failed to build create pull request job: %v", err) + } + + jobYAML := strings.Join(job.Steps, "") + + // Check expected strings are present + for _, expected := range tt.expectedInWith { + if !strings.Contains(jobYAML, expected) { + t.Errorf("Expected '%s' to be present in create PR job YAML, but it was not found", expected) + } + } + + // Check unexpected strings are not present + for _, unexpected := range tt.unexpectedInWith { + if strings.Contains(jobYAML, unexpected) { + t.Errorf("Expected '%s' to NOT be present in create PR job YAML, but it was found", unexpected) + } + } + } + }) + } +} + +func TestAddSafeOutputGitHubTokenFunction(t *testing.T) { + compiler := NewCompiler(false, "", "test") + + t.Run("Should add github-token when configured", func(t *testing.T) { + workflowData := &WorkflowData{ + SafeOutputs: &SafeOutputsConfig{ + GitHubToken: "${{ secrets.CUSTOM_PAT }}", + }, + } + + var steps []string + compiler.addSafeOutputGitHubToken(&steps, workflowData) + + if len(steps) != 1 { + t.Fatalf("Expected 1 step to be added, got %d", len(steps)) + } + + expectedStep := " github-token: ${{ secrets.CUSTOM_PAT }}\n" + if steps[0] != expectedStep { + t.Errorf("Expected step '%s', got '%s'", expectedStep, steps[0]) + } + }) + + t.Run("Should not add github-token when not configured", func(t *testing.T) { + workflowData := &WorkflowData{ + SafeOutputs: &SafeOutputsConfig{ + GitHubToken: "", + }, + } + + var steps []string + compiler.addSafeOutputGitHubToken(&steps, workflowData) + + if len(steps) != 0 { + t.Fatalf("Expected 0 steps to be added, got %d", len(steps)) + } + }) + + t.Run("Should not add github-token when SafeOutputs is nil", func(t *testing.T) { + workflowData := &WorkflowData{ + SafeOutputs: nil, + } + + var steps []string + compiler.addSafeOutputGitHubToken(&steps, workflowData) + + if len(steps) != 0 { + t.Fatalf("Expected 0 steps to be added, got %d", len(steps)) + } + }) +}