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: 15 additions & 0 deletions docs/src/content/docs/patterns/multirepoops.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down
14 changes: 8 additions & 6 deletions docs/src/content/docs/reference/frontmatter-full.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
10 changes: 8 additions & 2 deletions docs/src/content/docs/reference/safe-outputs.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
8 changes: 7 additions & 1 deletion docs/src/content/docs/reference/tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
52 changes: 52 additions & 0 deletions pkg/workflow/github_mcp_app_token_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
10 changes: 8 additions & 2 deletions pkg/workflow/safe_outputs_app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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: *)")
Comment on lines +143 to +147
Copy link

Copilot AI Feb 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

repositories treats "" specially only when it is the sole entry. If a user configures repositories: ["*", "repo1"], this will currently emit repositories: *,repo1 which is unlikely to be accepted by actions/create-github-app-token and won’t produce org-wide access. Consider validating that "" cannot be combined with other repository names (return a clear error) to avoid generating an invalid workflow.

Suggested change
// - 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: *)")
// - If repositories contains "*" along with other entries, treat this as invalid,
// log a clear message, and fall back to org-wide access (omit the field)
// - If repositories is specified with values (and does not contain "*"), use those specific repos
// - If repositories is empty/not specified, default to current repository
hasStar := false
for _, r := range app.Repositories {
if r == "*" {
hasStar = true
break
}
}
if hasStar {
if len(app.Repositories) == 1 {
// Org-wide access: omit repositories field entirely
safeOutputsAppLog.Print("Using org-wide GitHub App token (repositories: *)")
} else {
// Invalid configuration: "*" combined with other repositories
safeOutputsAppLog.Print("Invalid GitHub App repositories configuration: '*' cannot be combined with other repositories; using org-wide access instead")
}

Copilot uses AI. Check for mistakes.
} else if len(app.Repositories) > 0 {
reposStr := strings.Join(app.Repositories, ",")
steps = append(steps, fmt.Sprintf(" repositories: %s\n", reposStr))
} else {
Expand Down
51 changes: 51 additions & 0 deletions pkg/workflow/safe_outputs_app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading