diff --git a/docs/src/content/docs/reference/safe-outputs.md b/docs/src/content/docs/reference/safe-outputs.md index b876d600a0..8163675979 100644 --- a/docs/src/content/docs/reference/safe-outputs.md +++ b/docs/src/content/docs/reference/safe-outputs.md @@ -462,6 +462,7 @@ Manages GitHub Projects boards. Requires PAT or GitHub App token ([`GH_AW_PROJEC safe-outputs: update-project: max: 20 # max operations (default: 10) + project: "https://github.com/orgs/myorg/projects/42" # default project URL (optional) github-token: ${{ secrets.GH_AW_PROJECT_GITHUB_TOKEN }} views: # optional: auto-create views - name: "Sprint Board" @@ -473,7 +474,11 @@ safe-outputs: layout: roadmap ``` -Agent must provide full project URL (e.g., `https://github.com/orgs/myorg/projects/42`). Optional `campaign_id` applies `z_campaign_` labels for [Campaign Workflows](/gh-aw/guides/campaigns/). Exposes outputs: `project-id`, `project-number`, `project-url`, `campaign-id`, `item-id`. +**Configuration options:** +- `project` (optional): Default project URL for operations. When specified, agent messages can omit the `project` field and will use this URL by default. Overridden by explicit `project` field in agent output. +- Agent can provide full project URL (e.g., `https://github.com/orgs/myorg/projects/42`) in each message, or rely on the configured default. +- Optional `campaign_id` applies `z_campaign_` labels for [Campaign Workflows](/gh-aw/guides/campaigns/). +- Exposes outputs: `project-id`, `project-number`, `project-url`, `campaign-id`, `item-id`. #### Supported Field Types @@ -591,16 +596,20 @@ Creates status updates on GitHub Projects boards to communicate campaign progres safe-outputs: create-project-status-update: max: 1 # max updates per run (default: 1) + project: "https://github.com/orgs/myorg/projects/73" # default project URL (optional) github-token: ${{ secrets.GH_AW_PROJECT_GITHUB_TOKEN }} ``` -Agent provides full project URL, status update body (markdown), status indicator, and date fields. Typically used by [Campaign Workflows](/gh-aw/guides/campaigns/) to automatically post run summaries. +**Configuration options:** +- `project` (optional): Default project URL for status updates. When specified, agent messages can omit the `project` field and will use this URL by default. Overridden by explicit `project` field in agent output. +- Agent can provide full project URL in each message, or rely on the configured default. +- Typically used by [Campaign Workflows](/gh-aw/guides/campaigns/) to automatically post run summaries. #### Required Fields | Field | Type | Description | |-------|------|-------------| -| `project` | URL | Full GitHub project URL (e.g., `https://github.com/orgs/myorg/projects/73`) | +| `project` | URL | Full GitHub project URL (e.g., `https://github.com/orgs/myorg/projects/73`). Can be omitted if configured in safe-outputs. | | `body` | Markdown | Status update content with campaign summary, findings, and next steps | #### Optional Fields diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index b620458f66..ece57db1c7 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -4231,6 +4231,12 @@ "$ref": "#/$defs/github_token", "description": "GitHub token to use for this specific output type. Overrides global github-token if specified." }, + "project": { + "type": "string", + "description": "Default project URL for update-project operations. When specified, safe output messages can omit the project field and will use this URL by default. Must be a valid GitHub Projects v2 URL. Overridden by explicit project field in safe output messages.", + "pattern": "^https://github\\.com/(users|orgs)/([^/]+|<[A-Z_]+>)/projects/(\\d+|<[A-Z_]+>)$", + "examples": ["https://github.com/orgs/myorg/projects/123", "https://github.com/users/username/projects/456"] + }, "views": { "type": "array", "description": "Optional array of project views to create. Each view must have a name and layout. Views are created during project setup.", @@ -4481,6 +4487,12 @@ "github-token": { "$ref": "#/$defs/github_token", "description": "GitHub token to use for this specific output type. Overrides global github-token if specified. Must have Projects: Read+Write permission." + }, + "project": { + "type": "string", + "description": "Default project URL for status update operations. When specified, safe output messages can omit the project field and will use this URL by default. Must be a valid GitHub Projects v2 URL. Overridden by explicit project field in safe output messages.", + "pattern": "^https://github\\.com/(users|orgs)/([^/]+|<[A-Z_]+>)/projects/(\\d+|<[A-Z_]+>)$", + "examples": ["https://github.com/orgs/myorg/projects/123", "https://github.com/users/username/projects/456"] } }, "additionalProperties": false, diff --git a/pkg/workflow/compiler_safe_outputs_steps.go b/pkg/workflow/compiler_safe_outputs_steps.go index eba2a8fcf7..226b9840a7 100644 --- a/pkg/workflow/compiler_safe_outputs_steps.go +++ b/pkg/workflow/compiler_safe_outputs_steps.go @@ -239,13 +239,26 @@ func (c *Compiler) buildProjectHandlerManagerStep(data *WorkflowData) []string { token := getEffectiveProjectGitHubToken(customToken, data.GitHubToken) steps = append(steps, fmt.Sprintf(" GH_AW_PROJECT_GITHUB_TOKEN: %s\n", token)) - // Add GH_AW_PROJECT_URL if project is configured in frontmatter + // Add GH_AW_PROJECT_URL if project is configured in frontmatter or safe-outputs config // This provides a default project URL for update-project and create-project-status-update operations // when target=context (or target not specified). Users can override by setting target=* and // providing an explicit project field in the safe output message. + // + // Precedence: frontmatter project > update-project.project > create-project-status-update.project + var projectURL string if data.ParsedFrontmatter != nil && data.ParsedFrontmatter.Project != nil && data.ParsedFrontmatter.Project.URL != "" { - consolidatedSafeOutputsStepsLog.Printf("Adding GH_AW_PROJECT_URL environment variable: %s", data.ParsedFrontmatter.Project.URL) - steps = append(steps, fmt.Sprintf(" GH_AW_PROJECT_URL: %q\n", data.ParsedFrontmatter.Project.URL)) + projectURL = data.ParsedFrontmatter.Project.URL + consolidatedSafeOutputsStepsLog.Printf("Using project URL from frontmatter: %s", projectURL) + } else if data.SafeOutputs.UpdateProjects != nil && data.SafeOutputs.UpdateProjects.Project != "" { + projectURL = data.SafeOutputs.UpdateProjects.Project + consolidatedSafeOutputsStepsLog.Printf("Using project URL from update-project config: %s", projectURL) + } else if data.SafeOutputs.CreateProjectStatusUpdates != nil && data.SafeOutputs.CreateProjectStatusUpdates.Project != "" { + projectURL = data.SafeOutputs.CreateProjectStatusUpdates.Project + consolidatedSafeOutputsStepsLog.Printf("Using project URL from create-project-status-update config: %s", projectURL) + } + + if projectURL != "" { + steps = append(steps, fmt.Sprintf(" GH_AW_PROJECT_URL: %q\n", projectURL)) } // With section for github-token diff --git a/pkg/workflow/create_project_status_update.go b/pkg/workflow/create_project_status_update.go index 5c178b09b5..f73ca47631 100644 --- a/pkg/workflow/create_project_status_update.go +++ b/pkg/workflow/create_project_status_update.go @@ -10,6 +10,7 @@ var createProjectStatusUpdateLog = logger.New("workflow:create_project_status_up type CreateProjectStatusUpdateConfig struct { BaseSafeOutputConfig GitHubToken string `yaml:"github-token,omitempty"` // Optional custom GitHub token for project status updates + Project string `yaml:"project,omitempty"` // Optional default project URL for status updates } // parseCreateProjectStatusUpdateConfig handles create-project-status-update configuration @@ -29,10 +30,18 @@ func (c *Compiler) parseCreateProjectStatusUpdateConfig(outputMap map[string]any createProjectStatusUpdateLog.Print("Using custom GitHub token for create-project-status-update") } } + + // Parse project URL override if specified + if project, exists := configMap["project"]; exists { + if projectStr, ok := project.(string); ok { + config.Project = projectStr + createProjectStatusUpdateLog.Printf("Using custom project URL for create-project-status-update: %s", projectStr) + } + } } - createProjectStatusUpdateLog.Printf("Parsed create-project-status-update config: max=%d, hasCustomToken=%v", - config.Max, config.GitHubToken != "") + createProjectStatusUpdateLog.Printf("Parsed create-project-status-update config: max=%d, hasCustomToken=%v, hasCustomProject=%v", + config.Max, config.GitHubToken != "", config.Project != "") return config } createProjectStatusUpdateLog.Print("No create-project-status-update configuration found") diff --git a/pkg/workflow/create_project_status_update_handler_config_test.go b/pkg/workflow/create_project_status_update_handler_config_test.go index e3becc261b..e12efb5808 100644 --- a/pkg/workflow/create_project_status_update_handler_config_test.go +++ b/pkg/workflow/create_project_status_update_handler_config_test.go @@ -201,3 +201,40 @@ Test workflow assert.Contains(t, projectConfigJSON, `"create_project_status_update":{"max":2}`, "Expected create_project_status_update with max:2 in project handler config") } + +// TestCreateProjectStatusUpdateWithProjectURLConfig verifies that the project URL configuration +// is properly set as an environment variable when configured in safe-outputs +func TestCreateProjectStatusUpdateWithProjectURLConfig(t *testing.T) { + tmpDir := testutil.TempDir(t, "handler-config-test") + + testContent := `--- +name: Test Create Project Status Update with Project URL +on: workflow_dispatch +engine: copilot +safe-outputs: + create-project-status-update: + max: 1 + project: "https://github.com/orgs/nonexistent-test-org-67890/projects/88888" +--- + +Test workflow +` + + mdFile := filepath.Join(tmpDir, "test-workflow.md") + err := os.WriteFile(mdFile, []byte(testContent), 0600) + require.NoError(t, err, "Failed to write test markdown file") + + compiler := NewCompiler() + err = compiler.CompileWorkflow(mdFile) + require.NoError(t, err, "Failed to compile workflow") + + lockFile := filepath.Join(tmpDir, "test-workflow.lock.yml") + compiledContent, err := os.ReadFile(lockFile) + require.NoError(t, err, "Failed to read compiled output") + + compiledStr := string(compiledContent) + + // Verify GH_AW_PROJECT_URL environment variable is set + require.Contains(t, compiledStr, "GH_AW_PROJECT_URL:", "Expected GH_AW_PROJECT_URL environment variable") + require.Contains(t, compiledStr, "https://github.com/orgs/nonexistent-test-org-67890/projects/88888", "Expected project URL in environment variable") +} diff --git a/pkg/workflow/data/action_pins.json b/pkg/workflow/data/action_pins.json index 80130e4a1a..2db5179bb9 100644 --- a/pkg/workflow/data/action_pins.json +++ b/pkg/workflow/data/action_pins.json @@ -100,7 +100,7 @@ "version": "v5.6.0", "sha": "a26af69be951a213d495a4c3e4e4022e16d87065" }, - "actions/upload-artifact@v4": { + "actions/upload-artifact@v4.6.2": { "repo": "actions/upload-artifact", "version": "v4.6.2", "sha": "ea165f8d65b6e75b540449e92b4886f43607fa02" diff --git a/pkg/workflow/project_safe_outputs.go b/pkg/workflow/project_safe_outputs.go index 9499553ee4..c7b4607530 100644 --- a/pkg/workflow/project_safe_outputs.go +++ b/pkg/workflow/project_safe_outputs.go @@ -65,6 +65,12 @@ func (c *Compiler) applyProjectSafeOutputs(frontmatter map[string]any, existingS projectSafeOutputsLog.Print("update-project already configured, preserving existing configuration") } + // Enforce top-level project URL on update-project (security: stay within scope) + if safeOutputs.UpdateProjects != nil { + safeOutputs.UpdateProjects.Project = projectURL + projectSafeOutputsLog.Printf("Enforcing top-level project URL on update-project: %s", projectURL) + } + // Configure create-project-status-update if not already configured if safeOutputs.CreateProjectStatusUpdates == nil { projectSafeOutputsLog.Printf("Adding create-project-status-update safe-output (max: %d)", maxStatusUpdates) @@ -77,5 +83,11 @@ func (c *Compiler) applyProjectSafeOutputs(frontmatter map[string]any, existingS projectSafeOutputsLog.Print("create-project-status-update already configured, preserving existing configuration") } + // Enforce top-level project URL on create-project-status-update (security: stay within scope) + if safeOutputs.CreateProjectStatusUpdates != nil { + safeOutputs.CreateProjectStatusUpdates.Project = projectURL + projectSafeOutputsLog.Printf("Enforcing top-level project URL on create-project-status-update: %s", projectURL) + } + return safeOutputs } diff --git a/pkg/workflow/project_safe_outputs_test.go b/pkg/workflow/project_safe_outputs_test.go index 168923be9c..f4e20f5503 100644 --- a/pkg/workflow/project_safe_outputs_test.go +++ b/pkg/workflow/project_safe_outputs_test.go @@ -119,3 +119,63 @@ func TestProjectConfigIntegration(t *testing.T) { // Check create-project-status-update configuration assert.Equal(t, 1, result.CreateProjectStatusUpdates.Max, "CreateProjectStatusUpdates max should match") } + +func TestApplyProjectSafeOutputsEnforcesProjectURL(t *testing.T) { + compiler := NewCompiler() + projectURL := "https://github.com/orgs/nonexistent-test-org-99999/projects/99999" + + tests := []struct { + name string + frontmatter map[string]any + existingSafeOutputs *SafeOutputsConfig + expectEnforcement bool + }{ + { + name: "enforces project URL on newly created configs", + frontmatter: map[string]any{ + "project": projectURL, + }, + existingSafeOutputs: nil, + expectEnforcement: true, + }, + { + name: "enforces project URL on existing configs", + frontmatter: map[string]any{ + "project": projectURL, + }, + existingSafeOutputs: &SafeOutputsConfig{ + UpdateProjects: &UpdateProjectConfig{ + BaseSafeOutputConfig: BaseSafeOutputConfig{Max: 25}, + Project: "https://github.com/orgs/another-fake-org-88888/projects/88888", // Should be overridden + }, + CreateProjectStatusUpdates: &CreateProjectStatusUpdateConfig{ + BaseSafeOutputConfig: BaseSafeOutputConfig{Max: 3}, + Project: "https://github.com/orgs/another-fake-org-88888/projects/88888", // Should be overridden + }, + }, + expectEnforcement: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := compiler.applyProjectSafeOutputs(tt.frontmatter, tt.existingSafeOutputs) + + if tt.expectEnforcement { + require.NotNil(t, result, "Safe outputs should be created") + + // Verify update-project has enforced project URL + if result.UpdateProjects != nil { + assert.Equal(t, projectURL, result.UpdateProjects.Project, + "update-project.project should be enforced to top-level project URL") + } + + // Verify create-project-status-update has enforced project URL + if result.CreateProjectStatusUpdates != nil { + assert.Equal(t, projectURL, result.CreateProjectStatusUpdates.Project, + "create-project-status-update.project should be enforced to top-level project URL") + } + } + }) + } +} diff --git a/pkg/workflow/update_project.go b/pkg/workflow/update_project.go index 6c3a83e86d..4692c787bd 100644 --- a/pkg/workflow/update_project.go +++ b/pkg/workflow/update_project.go @@ -25,6 +25,7 @@ type ProjectFieldDefinition struct { type UpdateProjectConfig struct { BaseSafeOutputConfig `yaml:",inline"` GitHubToken string `yaml:"github-token,omitempty"` + Project string `yaml:"project,omitempty"` // Default project URL for operations Views []ProjectView `yaml:"views,omitempty"` FieldDefinitions []ProjectFieldDefinition `yaml:"field-definitions,omitempty" json:"field_definitions,omitempty"` } @@ -48,6 +49,14 @@ func (c *Compiler) parseUpdateProjectConfig(outputMap map[string]any) *UpdatePro } } + // Parse project URL override if specified + if project, exists := configMap["project"]; exists { + if projectStr, ok := project.(string); ok { + updateProjectConfig.Project = projectStr + updateProjectLog.Printf("Using custom project URL for update-project: %s", projectStr) + } + } + // Parse views if specified if viewsData, exists := configMap["views"]; exists { if viewsList, ok := viewsData.([]any); ok { @@ -155,8 +164,8 @@ func (c *Compiler) parseUpdateProjectConfig(outputMap map[string]any) *UpdatePro } } - updateProjectLog.Printf("Parsed update-project config: max=%d, hasCustomToken=%v, viewCount=%d, fieldDefinitionCount=%d", - updateProjectConfig.Max, updateProjectConfig.GitHubToken != "", len(updateProjectConfig.Views), len(updateProjectConfig.FieldDefinitions)) + updateProjectLog.Printf("Parsed update-project config: max=%d, hasCustomToken=%v, hasCustomProject=%v, viewCount=%d, fieldDefinitionCount=%d", + updateProjectConfig.Max, updateProjectConfig.GitHubToken != "", updateProjectConfig.Project != "", len(updateProjectConfig.Views), len(updateProjectConfig.FieldDefinitions)) return updateProjectConfig } updateProjectLog.Print("No update-project configuration found") diff --git a/pkg/workflow/update_project_handler_config_test.go b/pkg/workflow/update_project_handler_config_test.go index 742273a158..d7b35f7c9c 100644 --- a/pkg/workflow/update_project_handler_config_test.go +++ b/pkg/workflow/update_project_handler_config_test.go @@ -53,3 +53,38 @@ Test workflow "Expected field definitions in update_project handler config", ) } + +func TestUpdateProjectWithProjectURLConfig(t *testing.T) { + tmpDir := testutil.TempDir(t, "handler-config-test") + + testContent := `--- +name: Test Update Project with Project URL +on: workflow_dispatch +engine: copilot +safe-outputs: + update-project: + max: 5 + project: "https://github.com/orgs/nonexistent-test-org-12345/projects/99999" +--- + +Test workflow +` + + mdFile := filepath.Join(tmpDir, "test-workflow.md") + err := os.WriteFile(mdFile, []byte(testContent), 0600) + require.NoError(t, err, "Failed to write test markdown file") + + compiler := NewCompiler() + err = compiler.CompileWorkflow(mdFile) + require.NoError(t, err, "Failed to compile workflow") + + lockFile := filepath.Join(tmpDir, "test-workflow.lock.yml") + compiledContent, err := os.ReadFile(lockFile) + require.NoError(t, err, "Failed to read compiled output") + + compiledStr := string(compiledContent) + + // Verify GH_AW_PROJECT_URL environment variable is set + require.Contains(t, compiledStr, "GH_AW_PROJECT_URL:", "Expected GH_AW_PROJECT_URL environment variable") + require.Contains(t, compiledStr, "https://github.com/orgs/nonexistent-test-org-12345/projects/99999", "Expected project URL in environment variable") +}