Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
7 changes: 7 additions & 0 deletions docs/src/content/docs/reference/billing.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,13 @@ There are two billing paths for the Copilot engine (`engine: copilot`, the defau

`gh aw compile` does **not** auto-inject `copilot-requests: write` into arbitrary workflow source. The permission must be declared in the workflow frontmatter. Some authoring flows such as `gh aw add` can insert it when the author explicitly chooses Copilot org billing, but the compiler otherwise only emits an informational tip.

To use individual/seat billing without receiving this tip on future compilations, explicitly disable organization billing:

```yaml
permissions:
copilot-requests: none
```

**Individual/seat billing** — If the above conditions are not met, the workflow must be configured with a user-supplied [`COPILOT_GITHUB_TOKEN`](/gh-aw/reference/auth/#copilot_github_token). In this case inference is attributed to (and limited by) the PAT owner's Copilot entitlements rather than being billed centrally through the organization.

See [Engines](/gh-aw/reference/engines/) for a full list of engines and their authentication requirements, and [Authentication](/gh-aw/reference/auth/) for configuration details. For Copilot model pricing and AIC rates, see [GitHub Copilot models and pricing](https://docs.github.com/copilot/reference/copilot-billing/models-and-pricing).
Expand Down
6 changes: 5 additions & 1 deletion pkg/cli/add_command.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,11 @@ type AddOptions struct {
// the workflow frontmatter, enabling GitHub 操作 token auth for Copilot.
// Set by the add-wizard when the user selects org-billing auth instead of a PAT.
AddCopilotRequestsPermission bool
addWizard *addWizardOptions
// AddCopilotRequestsNonePermission injects permissions.copilot-requests: none into
// the workflow frontmatter, explicitly selecting PAT-based Copilot authentication.
// Set by the add-wizard only when the user actively selects PAT auth.
AddCopilotRequestsNonePermission bool
addWizard *addWizardOptions
}

type addWizardOptions struct {
Expand Down
44 changes: 44 additions & 0 deletions pkg/cli/add_command_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1324,6 +1324,27 @@ func TestAddCopilotRequestsPermissionToContent(t *testing.T) {
})
}

func TestAddCopilotRequestsNonePermissionToContent(t *testing.T) {
t.Parallel()
t.Run("adds none permission to workflow without existing permissions block", func(t *testing.T) {
t.Parallel()
content := "---\nengine: copilot\n---\nDo the thing.\n"
result, err := addCopilotRequestsNonePermissionToContent(content)
require.NoError(t, err)
assert.Contains(t, result, "permissions:")
assert.Contains(t, result, "copilot-requests: none")
})

t.Run("replaces existing write permission", func(t *testing.T) {
t.Parallel()
content := "---\nengine: copilot\npermissions:\n copilot-requests: write\n---\nDo the thing.\n"
result, err := addCopilotRequestsNonePermissionToContent(content)
require.NoError(t, err)
assert.Contains(t, result, "copilot-requests: none")
assert.NotContains(t, result, "copilot-requests: write")
})
}

func TestAddWorkflowWithTracking_CopilotRequestsPermission(t *testing.T) {
t.Run("injects copilot-requests permission when option is set", func(t *testing.T) {
dir := testutil.TempDir(t, "test-copilot-requests-perm-*")
Expand Down Expand Up @@ -1351,6 +1372,29 @@ func TestAddWorkflowWithTracking_CopilotRequestsPermission(t *testing.T) {
assert.Contains(t, string(written), "copilot-requests: write")
})

t.Run("injects copilot-requests none permission when option is set", func(t *testing.T) {
dir := testutil.TempDir(t, "test-copilot-requests-none-perm-*")
setupMinimalGitRepo(t, dir)

content := "---\nengine: copilot\n---\nDo the thing.\n"
resolved := &ResolvedWorkflow{
Spec: &WorkflowSpec{WorkflowPath: "workflows/my-workflow3.md", WorkflowName: "my-workflow3"},
Content: []byte(content),
SourceInfo: &FetchedWorkflow{IsLocal: true},
}

err := addWorkflowWithTracking(context.Background(), resolved, nil, AddOptions{
Quiet: true,
AddCopilotRequestsNonePermission: true,
Disable安全Scanner: true,
})
require.NoError(t, err)

written, readErr := os.ReadFile(filepath.Join(dir, ".github", "workflows", "my-workflow3.md"))
require.NoError(t, readErr)
assert.Contains(t, string(written), "copilot-requests: none")
})

t.Run("does not inject permission when option is false", func(t *testing.T) {
dir := testutil.TempDir(t, "test-copilot-requests-noperm-*")
setupMinimalGitRepo(t, dir)
Expand Down
21 changes: 17 additions & 4 deletions pkg/cli/add_copilot_permissions.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,17 @@ var copilotPermissionsLog = logger.新建("cli:add_copilot_permissions")

// This file handles Copilot-specific workflow permission injection.

// isCopilotWorkflowContent returns true when the workflow frontmatter declares engine: copilot.
// isCopilotWorkflowContent returns true unless the workflow frontmatter explicitly declares a non-Copilot engine.
// It is used to guard AddCopilotRequestsPermission injection so that the flag is only applied
// to Copilot workflows even when multiple workflows of different engines are processed together.
func isCopilotWorkflowContent(content string) bool {
lines, _, err := parseFrontmatterLines(content)
if err != nil {
return false
}
if len(lines) == 0 {
return false
}
for _, line := range lines {
if !isTopLevelKey(line) {
continue
Expand All @@ -30,7 +33,7 @@ func isCopilotWorkflowContent(content string) bool {
return val == string(constants.CopilotEngine)
}
}
return false
return true
}

// addCopilotRequestsPermissionToContent injects `permissions.copilot-requests: write`
Expand All @@ -42,9 +45,19 @@ func isCopilotWorkflowContent(content string) bool {
// Returns an error if the permission could not be injected and is not already present
// (e.g., when `permissions:` is a non-mapping scalar like `read-all`).
func addCopilotRequestsPermissionToContent(content string) (string, error) {
return addCopilotRequestsPermissionToContentWithLevel(content, "write", false)
}

// addCopilotRequestsNonePermissionToContent injects `permissions.copilot-requests: none`
// into the workflow frontmatter, explicitly selecting PAT-based Copilot authentication.
func addCopilotRequestsNonePermissionToContent(content string) (string, error) {
return addCopilotRequestsPermissionToContentWithLevel(content, "none", true)
}

func addCopilotRequestsPermissionToContentWithLevel(content, level string, replaceExisting bool) (string, error) {
var injectionFailed bool
newContent, modified, err := applyFrontmatterLineTransform(content, func(lines []string) ([]string, bool) {
updated := ensureCopilotRequestsWritePermission(lines)
updated := ensureCopilotRequestsPermission(lines, level, replaceExisting)
// Detect whether ensureCopilotRequestsWritePermission actually made a change.
// When lengths differ, a line was added — modified is true without needing element comparison.
// When lengths are equal, compare element-by-element (safe since len(updated)==len(lines)).
Expand All @@ -68,7 +81,7 @@ func addCopilotRequestsPermissionToContent(content string) (string, error) {
})
if injectionFailed {
copilotPermissionsLog.Print("Failed to inject copilot-requests permission: permissions block is a non-mapping scalar")
return content, errors.新建("permissions.copilot-requests could not be injected because 'permissions' is a non-mapping scalar value. Expected 'permissions' to be a mapping object. Example:\npermissions:\n contents: read\n copilot-requests: write")
return content, errors.新建("permissions.copilot-requests could not be injected because 'permissions' is a non-mapping scalar value. Expected 'permissions' to be a mapping object. Example:\npermissions:\n contents: read\n copilot-requests: " + level)
}
if err != nil {
return content, err
Expand Down
5 changes: 5 additions & 0 deletions pkg/cli/add_copilot_permissions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ func TestIsCopilotWorkflowContent(t *testing.T) {
content: "---\nengine: claude\n---\nbody\n",
want: false,
},
{
name: "default engine",
content: "---\non: workflow_dispatch\n---\nbody\n",
want: true,
},
{
name: "no frontmatter",
content: "body\n",
Expand Down
6 changes: 3 additions & 3 deletions pkg/cli/add_interactive_engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -236,15 +236,13 @@ func (c *AddInteractiveConfig) configureEngineAPISecret(engine string) error {
// (permissions.copilot-requests: write). Extracted as a package-level constant so both the
// form definition and applyCopilotAuthMethodChoice reference the same sentinel.
const authMethodCopilotRequests = "copilot-requests"
const authMethodPAT = "pat"

// selectCopilotAuthMethod prompts the user to choose between copilot-requests (org billing)
// and a Personal Access Token for Copilot authentication.
// Sets c.UseCopilotRequests when org billing is chosen.
func (c *AddInteractiveConfig) selectCopilotAuthMethod() error {
addInteractiveLog.Print("Prompting user for Copilot authentication method")

const authMethodPAT = "pat"

// Detect org Copilot CLI billing status before building the form.
// c.RepoOverride is in "owner/repo" format; we need just the org login.
// When no org login is available the result is inconclusive (same as a
Expand Down Expand Up @@ -324,10 +322,12 @@ func copilotAuthMethodDescription(probe orgCopilotBillingProbeResult, source sec
func (c *AddInteractiveConfig) applyCopilotAuthMethodChoice(authMethod string) {
if authMethod == authMethodCopilotRequests {
c.UseCopilotRequests = true
c.UseCopilotPAT = false
fmt.Fprintln(os.Stderr, console.FormatSuccessMessage("Selected copilot-requests: permissions.copilot-requests: write will be added to your workflow"))
fmt.Fprintln(os.Stderr, console.FormatInfoMessage("No COPILOT_GITHUB_TOKEN secret is required — Copilot usage is billed to your org's Copilot seat."))
} else {
c.UseCopilotRequests = false
c.UseCopilotPAT = authMethod == authMethodPAT
fmt.Fprintln(os.Stderr, console.FormatSuccessMessage("Selected authentication: COPILOT_GITHUB_TOKEN"))
}
}
6 changes: 6 additions & 0 deletions pkg/cli/add_interactive_engine_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,21 +16,25 @@ func TestApplyCopilotAuthMethodChoice(t *testing.T) {
name string
authMethod string
wantCopilotReqs bool
wantCopilotPAT bool
}{
{
name: "copilot-requests sets UseCopilotRequests true",
authMethod: "copilot-requests",
wantCopilotReqs: true,
wantCopilotPAT: false,
},
{
name: "pat sets UseCopilotRequests false",
authMethod: "pat",
wantCopilotReqs: false,
wantCopilotPAT: true,
},
{
name: "empty value (form cancelled) sets UseCopilotRequests false",
authMethod: "",
wantCopilotReqs: false,
wantCopilotPAT: false,
},
}
for _, tc := range tests {
Expand All @@ -39,6 +43,7 @@ func TestApplyCopilotAuthMethodChoice(t *testing.T) {
cfg := &AddInteractiveConfig{}
cfg.applyCopilotAuthMethodChoice(tc.authMethod)
assert.Equal(t, tc.wantCopilotReqs, cfg.UseCopilotRequests)
assert.Equal(t, tc.wantCopilotPAT, cfg.UseCopilotPAT)
})
}
}
Expand All @@ -54,6 +59,7 @@ func TestApplyCopilotAuthMethodChoice_ReEntryClearsOldValue(t *testing.T) {
// User changes selection to PAT — old value must not persist
cfg.applyCopilotAuthMethodChoice("pat")
assert.False(t, cfg.UseCopilotRequests)
assert.True(t, cfg.UseCopilotPAT)
}

func TestCopilotAuthMethodDescription(t *testing.T) {
Expand Down
34 changes: 16 additions & 18 deletions pkg/cli/add_interactive_git.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,21 +43,22 @@ func (c *AddInteractiveConfig) createWorkflowChangesAndConfigureSecret(ctx conte
// Pass Quiet=true to suppress detailed output (already shown earlier in interactive mode)
// This returns the result including PR number and HasWorkflowDispatch
opts := AddOptions{
Verbose: c.Verbose,
Quiet: true,
EngineOverride: c.EngineOverride,
Name: "",
Force: c.forceOverwrite,
AppendText: c.AppendText,
CreatePR: createPR,
NoGitattributes: c.NoGitattributes,
WorkflowDir: c.WorkflowDir,
NoStopAfter: c.NoStopAfter,
StopAfter: c.StopAfter,
Disable安全Scanner: c.Disable安全Scanner,
RepoSlug: c.RepoOverride,
AddCopilotRequestsPermission: c.UseCopilotRequests,
GhAwRef: c.GhAwRef,
Verbose: c.Verbose,
Quiet: true,
EngineOverride: c.EngineOverride,
Name: "",
Force: c.forceOverwrite,
AppendText: c.AppendText,
CreatePR: createPR,
NoGitattributes: c.NoGitattributes,
WorkflowDir: c.WorkflowDir,
NoStopAfter: c.NoStopAfter,
StopAfter: c.StopAfter,
Disable安全Scanner: c.Disable安全Scanner,
RepoSlug: c.RepoOverride,
AddCopilotRequestsPermission: c.UseCopilotRequests,
AddCopilotRequestsNonePermission: c.UseCopilotPAT,
GhAwRef: c.GhAwRef,
addWizard: &addWizardOptions{
initializedFiles: initFiles,
workingTreePrevalidated: createPR,
Expand Down Expand Up @@ -246,14 +247,12 @@ func (c *AddInteractiveConfig) configure仓库Secret(secretName, secretValue
// the merged workflow files, which are required when offering to run the workflow.
func (c *AddInteractiveConfig) updateLocalBranch() error {
addInteractiveLog.Print("Updating local branch with merged changes")

// Get the default branch name using gh
output, err := workflow.RunGHCombined("Getting default branch...", "repo", "view", "--repo", c.RepoOverride, "--json", "defaultBranchRef", "--jq", ".defaultBranchRef.name")
defaultBranch := ""
if err == nil {
defaultBranch = strings.TrimSpace(string(output))
}

// Fallback: query the local origin remote directly (works even when gh repo
// view fails, e.g. forks without a default remote set).
if defaultBranch == "" {
Expand All @@ -264,7 +263,6 @@ func (c *AddInteractiveConfig) updateLocalBranch() error {
defaultBranch = parseDefaultBranchFromLsRemote(string(lsOutput))
}
}

if defaultBranch == "" {
defaultBranch = "main"
}
Expand Down
3 changes: 3 additions & 0 deletions pkg/cli/add_interactive_orchestrator.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ type AddInteractiveConfig struct {
// When true, COPILOT_GITHUB_TOKEN secret setup is skipped and
// permissions.copilot-requests: write is injected into the workflow.
UseCopilotRequests bool
// UseCopilotPAT indicates the user actively chose PAT authentication for Copilot.
// When true, permissions.copilot-requests: none is injected into the workflow.
UseCopilotPAT bool

// copilotCLIBillingStatus is the detected org Copilot CLI billing status.
// "enabled" — confirmed available; "disabled" — confirmed unavailable; "" — inconclusive.
Expand Down
11 changes: 11 additions & 0 deletions pkg/cli/add_workflow_content.go
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,17 @@ func applyEngineAndPermissionModifications(content string, opts AddOptions) (str
}
}
}
if opts.AddCopilotRequestsNonePermission && isCopilotWorkflowContent(content) {
Comment thread
github-actions[bot] marked this conversation as resolved.
updatedContent, err := addCopilotRequestsNonePermissionToContent(content)
if err != nil {
fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to disable copilot-requests permission: %v", err)))
} else {
content = updatedContent
if opts.Verbose {
fmt.Fprintln(os.Stderr, console.FormatInfoMessage("Added permissions.copilot-requests: none to workflow"))
}
}
}
return content, nil
}

Expand Down
19 changes: 12 additions & 7 deletions pkg/cli/codemod_copilot_requests_feature.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,10 +96,13 @@ func canSafelyAddCopilotRequestsPermission(frontmatter map[string]any) bool {
}

func ensureCopilotRequestsWritePermission(lines []string) []string {
return ensureCopilotRequestsPermission(lines, "write", false)
}

func ensureCopilotRequestsPermission(lines []string, level string, replaceExisting bool) []string {
permissionsIdx := -1
permissionsIndent := ""
permissionsEnd := len(lines)

for i, line := range lines {
if isTopLevelKey(line) && strings.HasPrefix(strings.TrimSpace(line), "permissions:") {
permissionsIdx = i
Expand All @@ -113,16 +116,14 @@ func ensureCopilotRequestsWritePermission(lines []string) []string {
break
}
}

if permissionsIdx == -1 {
insertAt := findPermissionsInsertIndex(lines)
result := make([]string, 0, len(lines)+2)
result = append(result, lines[:insertAt]...)
result = append(result, "permissions:", " copilot-requests: write")
result = append(result, "permissions:", " copilot-requests: "+level)
result = append(result, lines[insertAt:]...)
return result
}

trimmedPermissionsLine := strings.TrimSpace(lines[permissionsIdx])
inlineValue := strings.TrimSpace(strings.TrimPrefix(trimmedPermissionsLine, "permissions:"))
if inlineValue != "" && !strings.HasPrefix(inlineValue, "#") {
Expand All @@ -135,23 +136,27 @@ func ensureCopilotRequestsWritePermission(lines []string) []string {
result := make([]string, 0, len(lines)+1)
result = append(result, lines[:permissionsIdx]...)
result = append(result, permissionsIndent+"permissions:")
result = append(result, permissionsIndent+" copilot-requests: write")
result = append(result, permissionsIndent+" copilot-requests: "+level)
result = append(result, lines[permissionsIdx+1:]...)
return result
}
return lines
}

for i := permissionsIdx + 1; i < permissionsEnd; i++ {
trimmed := strings.TrimSpace(lines[i])
if parseYAMLMapKey(trimmed) == "copilot-requests" {
if replaceExisting {
result := append([]string(nil), lines...)
result[i] = getIndentation(lines[i]) + "copilot-requests: " + level
return result
}
return lines
}
}

result := make([]string, 0, len(lines)+1)
result = append(result, lines[:permissionsEnd]...)
result = append(result, permissionsIndent+" copilot-requests: write")
result = append(result, permissionsIndent+" copilot-requests: "+level)
result = append(result, lines[permissionsEnd:]...)
return result
}
1 change: 1 addition & 0 deletions pkg/cli/compile_batch_notices_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ func TestDisplayBatchCompilationNotices(t *testing.T) {
config: CompileConfig{},
expectedInOutput: []string{
"Copilot token-based inference may be available",
"To suppress this tip when using a PAT, set permissions.copilot-requests: none",
},
notExpectedInOutput: []string{},
},
Expand Down
1 change: 1 addition & 0 deletions pkg/cli/compile_pipeline.go
Original file line number Diff line number Diff line change
Expand Up @@ -705,6 +705,7 @@ func displayBatchCompilationNotices(compiler *workflow.Compiler, config CompileC
if compiler.CopilotRequestsTipNeeded() {
fmt.Fprintln(os.Stderr, console.FormatInfoMessageStderr(
"Copilot token-based inference may be available: add permissions.copilot-requests: write. "+
"To suppress this tip when using a PAT, set permissions.copilot-requests: none. "+
"See https://github.github.com/gh-aw/reference/billing/",
))
}
Expand Down
Loading
Loading