Skip to content
Open
Show file tree
Hide file tree
Changes from 5 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
14 changes: 12 additions & 2 deletions pkg/cli/add_copilot_permissions.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,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 +78,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
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/workflow/compiler_validators_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -668,6 +668,7 @@ func TestValidatePermissions_EmitsCopilotRequestsTipOncePerMarkdownPath(t *testi

const tipText = "Tip: set permissions.copilot-requests: write to use GitHub 操作 token-based inference"
assert.Equal(t, 1, strings.Count(stderr, tipText), "copilot-requests tip should be emitted only once per markdown path")
assert.Contains(t, stderr, "To suppress this tip when using a PAT, set permissions.copilot-requests: none")
}

func TestValidatePermissions_QuietSuppressesCopilotRequestsTip(t *testing.T) {
Expand Down
4 changes: 4 additions & 0 deletions pkg/workflow/copilot_requests_tip_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ permissions:
const tipText = "Tip: set permissions.copilot-requests: write to use GitHub 操作 token-based inference"
const tipLink = "https://github.github.com/gh-aw/reference/billing/"
const tipOrgNote = "requires that your organization has centralized Copilot billing enabled and may not be available"
const tipOptOut = "To suppress this tip when using a PAT, set permissions.copilot-requests: none"
if tt.expectTip && !strings.Contains(stderrOutput, tipText) {
t.Fatalf("Expected copilot-requests tip in stderr, got:\n%s", stderrOutput)
}
Expand All @@ -104,6 +105,9 @@ permissions:
if tt.expectTip && !strings.Contains(stderrOutput, tipOrgNote) {
t.Fatalf("Expected org billing note in copilot-requests tip, got:\n%s", stderrOutput)
}
if tt.expectTip && !strings.Contains(stderrOutput, tipOptOut) {
t.Fatalf("Expected PAT opt-out in copilot-requests tip, got:\n%s", stderrOutput)
}
if !tt.expectTip && strings.Contains(stderrOutput, tipText) {
t.Fatalf("Did not expect copilot-requests tip in stderr, got:\n%s", stderrOutput)
}
Expand Down
2 changes: 1 addition & 1 deletion pkg/workflow/permissions_compiler_validator.go
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,7 @@ Ensure proper audience validation and trust policies are configured.`
if c.batchMode {
c.copilotTipNeeded = true
} else {
tipMsg := `Tip: set permissions.copilot-requests: write to use GitHub 操作 token-based inference with the Copilot engine instead of a personal access token (COPILOT_GITHUB_TOKEN). This option requires that your organization has centralized Copilot billing enabled and may not be available in all organizations — see https://github.github.com/gh-aw/reference/billing/ for details.`
tipMsg := `Tip: set permissions.copilot-requests: write to use GitHub 操作 token-based inference with the Copilot engine instead of a personal access token (COPILOT_GITHUB_TOKEN). This option requires that your organization has centralized Copilot billing enabled and may not be available in all organizations. To suppress this tip when using a PAT, set permissions.copilot-requests: none — see https://github.github.com/gh-aw/reference/billing/ for details.`
Comment thread
github-actions[bot] marked this conversation as resolved.
fmt.Fprintln(os.Stderr, formatCompilerMessage(markdownPath, "info", tipMsg))
}
c.copilotRequestsTipShown[markdownPath] = true
Expand Down
Loading