Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
15 changes: 12 additions & 3 deletions docs/src/content/docs/reference/safe-outputs.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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_<id>` 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_<id>` labels for [Campaign Workflows](/gh-aw/guides/campaigns/).
- Exposes outputs: `project-id`, `project-number`, `project-url`, `campaign-id`, `item-id`.

#### Supported Field Types

Expand Down Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions pkg/parser/schemas/main_workflow_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down Expand Up @@ -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,
Expand Down
19 changes: 16 additions & 3 deletions pkg/workflow/compiler_safe_outputs_steps.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 11 additions & 2 deletions pkg/workflow/create_project_status_update.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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")
Expand Down
37 changes: 37 additions & 0 deletions pkg/workflow/create_project_status_update_handler_config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
2 changes: 1 addition & 1 deletion pkg/workflow/data/action_pins.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
12 changes: 12 additions & 0 deletions pkg/workflow/project_safe_outputs.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
}
60 changes: 60 additions & 0 deletions pkg/workflow/project_safe_outputs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
})
}
}
13 changes: 11 additions & 2 deletions pkg/workflow/update_project.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}
Expand All @@ -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 {
Expand Down Expand Up @@ -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")
Expand Down
35 changes: 35 additions & 0 deletions pkg/workflow/update_project_handler_config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Loading