diff --git a/docs/src/content/docs/patterns/multirepoops.md b/docs/src/content/docs/patterns/multirepoops.md index 6e1b42eeab..7335d298a0 100644 --- a/docs/src/content/docs/patterns/multirepoops.md +++ b/docs/src/content/docs/patterns/multirepoops.md @@ -78,6 +78,8 @@ The PAT needs permissions **only on target repositories** where you want to crea For enhanced security, use GitHub Apps with automatic token revocation: +**Specific repositories:** + ```yaml wrap safe-outputs: app: @@ -89,6 +91,19 @@ safe-outputs: target-repo: "my-org/repo1" ``` +**Org-wide access** (all repos in installation): + +```yaml wrap +safe-outputs: + app: + app-id: ${{ vars.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + owner: "my-org" + repositories: ["*"] # Access all repos + create-issue: + target-repo: "my-org/repo1" +``` + See [Safe Outputs Reference](/gh-aw/reference/safe-outputs/) for complete authentication configuration. ## Common MultiRepoOps Patterns diff --git a/docs/src/content/docs/reference/frontmatter-full.md b/docs/src/content/docs/reference/frontmatter-full.md index 23000f9826..b6e14a3a44 100644 --- a/docs/src/content/docs/reference/frontmatter-full.md +++ b/docs/src/content/docs/reference/frontmatter-full.md @@ -1371,8 +1371,10 @@ tools: # (optional) owner: "example-value" - # Optional list of repositories to grant access to (defaults to current repository - # if not specified) + # Optional list of repositories to grant access to. Supports three modes: + # - ["*"] for org-wide access (all repos in the installation) + # - ["repo1", "repo2"] for specific repositories only + # - Empty/omit for current repository only (default) # (optional) repositories: [] # Array of strings @@ -3544,10 +3546,10 @@ safe-outputs: # (optional) owner: "example-value" - # Optional: Comma or newline-separated list of repositories to grant access to. If - # owner is set and repositories is empty, access will be scoped to all - # repositories in the provided repository owner's installation. If owner and - # repositories are empty, access will be scoped to only the current repository. + # Optional: List of repositories to grant access to. Supports three modes: + # - ["*"] for org-wide access (all repos in the installation) + # - ["repo1", "repo2"] for specific repositories only + # - Empty/omit for current repository only (default) # (optional) repositories: [] # Array of strings diff --git a/docs/src/content/docs/reference/safe-outputs.md b/docs/src/content/docs/reference/safe-outputs.md index 2759c414d0..30ed856370 100644 --- a/docs/src/content/docs/reference/safe-outputs.md +++ b/docs/src/content/docs/reference/safe-outputs.md @@ -1458,11 +1458,17 @@ safe-outputs: app: app-id: ${{ vars.APP_ID }} private-key: ${{ secrets.APP_PRIVATE_KEY }} - owner: "my-org" # optional: installation owner - repositories: ["repo1", "repo2"] # optional: scope to repos + owner: "my-org" # optional: installation owner + repositories: ["repo1", "repo2"] # optional: scope to specific repos create-issue: ``` +**Repository scoping options**: + +- `repositories: ["*"]` - Org-wide access (all repos in the installation) +- `repositories: ["repo1", "repo2"]` - Specific repositories only +- Omit `repositories` field - Current repository only (default) + #### How GitHub App Tokens Work When you configure `app:` for safe outputs, tokens are **automatically managed per-job** for enhanced security: diff --git a/docs/src/content/docs/reference/tools.md b/docs/src/content/docs/reference/tools.md index a38cb7a832..8c739493c6 100644 --- a/docs/src/content/docs/reference/tools.md +++ b/docs/src/content/docs/reference/tools.md @@ -133,9 +133,15 @@ tools: app-id: ${{ vars.APP_ID }} private-key: ${{ secrets.APP_PRIVATE_KEY }} owner: "my-org" # Optional: defaults to current repo owner - repositories: ["repo1", "repo2"] # Optional: defaults to current repo only + repositories: ["repo1", "repo2"] # Optional: scope to specific repos ``` +**Repository scoping options**: + +- `repositories: ["*"]` - Org-wide access (all repos in the installation) +- `repositories: ["repo1", "repo2"]` - Specific repositories only +- Omit `repositories` field - Current repository only (default) + **Shared workflow pattern** (recommended): ```yaml wrap diff --git a/pkg/workflow/github_mcp_app_token_test.go b/pkg/workflow/github_mcp_app_token_test.go index b6d4a47d15..c2753408c2 100644 --- a/pkg/workflow/github_mcp_app_token_test.go +++ b/pkg/workflow/github_mcp_app_token_test.go @@ -212,3 +212,55 @@ Test app token with remote GitHub MCP Server. assert.Contains(t, lockContent, "GITHUB_MCP_SERVER_TOKEN: ${{ steps.github-mcp-app-token.outputs.token }}", "Should use app token for GitHub MCP Server in remote mode") } } + +// TestGitHubMCPAppTokenOrgWide tests org-wide GitHub MCP token with wildcard +func TestGitHubMCPAppTokenOrgWide(t *testing.T) { + compiler := NewCompilerWithVersion("1.0.0") + + markdown := `--- +on: issues +permissions: + contents: read + issues: read +strict: false +tools: + github: + mode: local + app: + app-id: ${{ vars.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + repositories: + - "*" +--- + +# Test Workflow + +Test org-wide GitHub MCP app token. +` + + // Create a temporary test file + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "test.md") + err := os.WriteFile(testFile, []byte(markdown), 0644) + require.NoError(t, err, "Failed to write test file") + + // Compile the workflow + err = compiler.CompileWorkflow(testFile) + require.NoError(t, err, "Failed to compile workflow") + + // Read the generated lock file (same name with .lock.yml extension) + lockFile := strings.TrimSuffix(testFile, ".md") + ".lock.yml" + content, err := os.ReadFile(lockFile) + require.NoError(t, err, "Failed to read lock file") + lockContent := string(content) + + // Verify token minting step is present + assert.Contains(t, lockContent, "Generate GitHub App token", "Token minting step should be present") + + // Verify repositories field is NOT present (org-wide access) + assert.NotContains(t, lockContent, "repositories:", "Should not include repositories field for org-wide access") + + // Verify other fields are still present + assert.Contains(t, lockContent, "owner:", "Should include owner field") + assert.Contains(t, lockContent, "app-id:", "Should include app-id field") +} diff --git a/pkg/workflow/safe_outputs_app.go b/pkg/workflow/safe_outputs_app.go index 3ddf91f8bd..3bebf4a6ab 100644 --- a/pkg/workflow/safe_outputs_app.go +++ b/pkg/workflow/safe_outputs_app.go @@ -138,8 +138,14 @@ func (c *Compiler) buildGitHubAppTokenMintStep(app *GitHubAppConfig, permissions } steps = append(steps, fmt.Sprintf(" owner: %s\n", owner)) - // Add repositories - default to current repository name if not specified - if len(app.Repositories) > 0 { + // Add repositories - behavior depends on configuration: + // - If repositories is ["*"], omit the field to allow org-wide access + // - If repositories is specified with values, use those specific repos + // - If repositories is empty/not specified, default to current repository + if len(app.Repositories) == 1 && app.Repositories[0] == "*" { + // Org-wide access: omit repositories field entirely + safeOutputsAppLog.Print("Using org-wide GitHub App token (repositories: *)") + } else if len(app.Repositories) > 0 { reposStr := strings.Join(app.Repositories, ",") steps = append(steps, fmt.Sprintf(" repositories: %s\n", reposStr)) } else { diff --git a/pkg/workflow/safe_outputs_app_test.go b/pkg/workflow/safe_outputs_app_test.go index b9de47c823..cb481a442b 100644 --- a/pkg/workflow/safe_outputs_app_test.go +++ b/pkg/workflow/safe_outputs_app_test.go @@ -207,6 +207,57 @@ Test workflow without safe outputs. assert.Nil(t, workflowData.SafeOutputs, "SafeOutputs should be nil") } +// TestSafeOutputsAppTokenOrgWide tests org-wide GitHub App token with wildcard +func TestSafeOutputsAppTokenOrgWide(t *testing.T) { + compiler := NewCompilerWithVersion("1.0.0") + + markdown := `--- +on: issues +permissions: + contents: read +safe-outputs: + create-issue: + app: + app-id: ${{ vars.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + repositories: + - "*" +--- + +# Test Workflow + +Test workflow with org-wide app token. +` + + // Create a temporary test file + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "test.md") + err := os.WriteFile(testFile, []byte(markdown), 0644) + require.NoError(t, err, "Failed to write test file") + + workflowData, err := compiler.ParseWorkflowFile(testFile) + require.NoError(t, err, "Failed to parse markdown content") + + // Build the safe_outputs job + job, err := compiler.buildCreateOutputIssueJob(workflowData, "main") + require.NoError(t, err, "Failed to build safe_outputs job") + require.NotNil(t, job, "Job should not be nil") + + // Convert steps to string for easier assertion + stepsStr := strings.Join(job.Steps, "") + + // Verify token minting step is present + assert.Contains(t, stepsStr, "Generate GitHub App token", "Token minting step should be present") + assert.Contains(t, stepsStr, "actions/create-github-app-token", "Should use create-github-app-token action") + + // Verify repositories field is NOT present (org-wide access) + assert.NotContains(t, stepsStr, "repositories:", "Should not include repositories field for org-wide access") + + // Verify other fields are still present + assert.Contains(t, stepsStr, "owner:", "Should include owner field") + assert.Contains(t, stepsStr, "app-id:", "Should include app-id field") +} + // TestSafeOutputsAppTokenDiscussionsPermission tests that discussions permission is included func TestSafeOutputsAppTokenDiscussionsPermission(t *testing.T) { compiler := NewCompilerWithVersion("1.0.0")