Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
47d2dc8
feat: add internal secret helper
mnkiefer Dec 12, 2025
5d1235a
Merge branch 'main' into token-getter
mnkiefer Dec 12, 2025
10c2185
make fmt
mnkiefer Dec 12, 2025
29e40a7
recompile
mnkiefer Dec 12, 2025
fd46058
Update internal/tools/ghsecret/main.go
mnkiefer Dec 12, 2025
e91c967
Update docs/src/content/docs/reference/tokens.md
mnkiefer Dec 12, 2025
e83c891
fix: add missing cobra import to tokens_bootstrap.go (#6233)
Copilot Dec 12, 2025
3268caa
Merge branch 'main' into token-getter
mnkiefer Dec 12, 2025
6ad5d49
Merge branch 'main' into token-getter
mnkiefer Dec 12, 2025
cc60e30
use go-gh package
mnkiefer Dec 12, 2025
64f0bf0
refactor: migrate ghsecret to 'gh aw secret set' subcommand
mnkiefer Dec 12, 2025
907bd59
enhance token management with engine-specific recommendations and opt…
mnkiefer Dec 12, 2025
7fe09a3
feat: enhance tokens bootstrap command with repository owner and name…
mnkiefer Dec 12, 2025
3d7a039
integrated secret mgt into the gh aw init flow and updated install.md
mnkiefer Dec 12, 2025
4eb436e
Merge branch 'main' into token-getter
mnkiefer Dec 12, 2025
1c7c165
make fmt
mnkiefer Dec 12, 2025
68606c0
fix tests to include new parameters
mnkiefer Dec 12, 2025
b3954a6
Merge branch 'main' into token-getter
mnkiefer Dec 12, 2025
883c384
Merge branch 'main' into token-getter
mnkiefer Dec 12, 2025
2b3c52f
give owner/repo defaults
mnkiefer Dec 12, 2025
b9d6bdb
Merge branch 'main' into token-getter
mnkiefer Dec 12, 2025
ca13f6f
Merge branch 'main' into token-getter
mnkiefer Dec 12, 2025
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
3 changes: 3 additions & 0 deletions cmd/gh-aw/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"strings"

"github.com/githubnext/gh-aw/pkg/campaign"
"github.com/githubnext/gh-aw/pkg/cli"

Check failure on line 9 in cmd/gh-aw/main.go

View workflow job for this annotation

GitHub Actions / lint

could not import github.com/githubnext/gh-aw/pkg/cli (-: # github.com/githubnext/gh-aw/pkg/cli

Check failure on line 9 in cmd/gh-aw/main.go

View workflow job for this annotation

GitHub Actions / lint

could not import github.com/githubnext/gh-aw/pkg/cli (-: # github.com/githubnext/gh-aw/pkg/cli
"github.com/githubnext/gh-aw/pkg/console"
"github.com/githubnext/gh-aw/pkg/constants"
"github.com/spf13/cobra"
Expand Down Expand Up @@ -494,6 +494,7 @@
mcpGatewayCmd := cli.NewMCPGatewayCommand()
prCmd := cli.NewPRCommand()
campaignCmd := campaign.NewCommand()
tokensCmd := cli.NewTokensCommand()

// Assign commands to groups
// Setup Commands
Expand All @@ -502,6 +503,7 @@
addCmd.GroupID = "setup"
removeCmd.GroupID = "setup"
updateCmd.GroupID = "setup"
tokensCmd.GroupID = "setup"

// Development Commands
compileCmd.GroupID = "development"
Expand Down Expand Up @@ -546,6 +548,7 @@
rootCmd.AddCommand(prCmd)
rootCmd.AddCommand(versionCmd)
rootCmd.AddCommand(campaignCmd)
rootCmd.AddCommand(tokensCmd)
}

func main() {
Expand Down
44 changes: 44 additions & 0 deletions docs/src/content/docs/reference/tokens.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,50 @@ sidebar:

GitHub Agentic Workflows authenticate using multiple tokens depending on the operation. This reference explains which token to use, when it's required, and how precedence works across different operations.

## Quick start: tokens you actually configure

GitHub Actions always provides `GITHUB_TOKEN` for you automatically.
For GitHub Agentic Workflows, you only need to create a few **optional** secrets in your own repo:

| When you need this… | Secret to create | Notes |
|------------------------------------------------------|----------------------------------------|-------|
| Cross-repo Project Ops / remote GitHub tools | `GH_AW_GITHUB_TOKEN` | PAT or app token with cross-repo access. |
| Copilot workflows (CLI, engine, agent tasks, etc.) | `COPILOT_GITHUB_TOKEN` | Needs Copilot Requests permission and repo access. |
| Assigning agents/bots to issues or pull requests | `GH_AW_AGENT_TOKEN` | Used by `assign-to-agent` and Copilot assignee/reviewer flows. |
| Isolating MCP server permissions (advanced optional) | `GH_AW_GITHUB_MCP_SERVER_TOKEN` | Only if you want MCP to use a different token than other jobs. |

Create these as **repository or organization secrets in *your* repo**, for example with the GitHub CLI:

```bash
gh secret set GH_AW_GITHUB_TOKEN -a actions --body "YOUR_PAT"
gh secret set COPILOT_GITHUB_TOKEN -a actions --body "YOUR_COPILOT_PAT"
gh secret set GH_AW_AGENT_TOKEN -a actions --body "YOUR_AGENT_PAT"
Comment thread
mnkiefer marked this conversation as resolved.
Outdated
```

After these are set, gh-aw will automatically pick the right token for each operation; you should not need per-workflow PATs in most cases.

### Security and scopes (least privilege)

- Use `permissions:` at the workflow or job level so `GITHUB_TOKEN` only has what that workflow needs (for example, read contents and write PRs, but nothing else):

```yaml
permissions:
contents: read
pull-requests: write
```

- When creating each PAT/App token above, grant access **only** to the repos and scopes required for its scenario (cross-repo Project Ops, Copilot, agents, or MCP) and nothing more.
- Only expose powerful secrets to the jobs that need them by scoping them to `env:` at the job or step level, not globally:

```yaml
jobs:
project-ops:
env:
GH_AW_GITHUB_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN }}
```

- For very sensitive tokens, prefer GitHub Environments or organization-level secrets with required reviewers so only trusted workflows can use them.

## Token Overview

| Token | Type | Purpose | User Configurable |
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ require (
github.com/spf13/cobra v1.10.2
github.com/stretchr/testify v1.11.1
github.com/xeipuuv/gojsonschema v1.2.0
golang.org/x/crypto v0.36.0
golang.org/x/term v0.38.0
gopkg.in/yaml.v3 v3.0.1
)
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,8 @@ github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT0
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
Expand Down
236 changes: 236 additions & 0 deletions internal/tools/ghsecret/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
package main
Comment thread
mnkiefer marked this conversation as resolved.
Outdated

import (
"bufio"
"crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"log"
"net/http"
"net/url"
"os"
"strings"

"golang.org/x/crypto/nacl/box"
)

type repoPublicKey struct {
ID string `json:"key_id"`
Key string `json:"key"`
}

type secretPayload struct {
EncryptedValue string `json:"encrypted_value"`
KeyID string `json:"key_id"`
}

func main() {
var (
flagOwner = flag.String("owner", "", "GitHub repository owner or organization")
flagRepo = flag.String("repo", "", "GitHub repository name")
flagSecretName = flag.String("secret", "", "Secret name to create or update")
flagValue = flag.String("value", "", "Secret value (if empty, read from stdin)")
flagValueEnv = flag.String("value-from-env", "", "Environment variable to read secret value from")
flagAPIBase = flag.String("api-url", "", "GitHub API base URL (default: https://api.github.com or $GITHUB_API_URL)")
)

flag.Parse()

if *flagOwner == "" || *flagRepo == "" || *flagSecretName == "" {
flag.Usage()
os.Exit(1)
}

apiBase := resolveAPIBase(*flagAPIBase)
token, err := resolveToken()
if err != nil {
log.Fatalf("cannot resolve GitHub token: %v", err)
}

secretValue, err := resolveSecretValue(*flagValueEnv, *flagValue)
if err != nil {
log.Fatalf("cannot resolve secret value: %v", err)
}

if err := setRepoSecret(apiBase, token, *flagOwner, *flagRepo, *flagSecretName, secretValue); err != nil {
log.Fatalf("failed to set secret: %v", err)
}

fmt.Printf("Secret %s updated for %s/%s\n", *flagSecretName, *flagOwner, *flagRepo)
}

func resolveAPIBase(flagValue string) string {
Comment thread
mnkiefer marked this conversation as resolved.
Outdated
candidates := []string{
strings.TrimSpace(flagValue),
strings.TrimSpace(os.Getenv("GITHUB_API_URL")),
}

for _, c := range candidates {
if c != "" {
return strings.TrimRight(c, "/")
}
}

return "https://api.github.com"
}

func resolveToken() (string, error) {
for _, name := range []string{"GITHUB_TOKEN", "GH_TOKEN"} {
if v := strings.TrimSpace(os.Getenv(name)); v != "" {
return v, nil
}
}
return "", errors.New("no token found; set GITHUB_TOKEN or GH_TOKEN")
Comment thread
mnkiefer marked this conversation as resolved.
Outdated
}

func resolveSecretValue(fromEnv, fromFlag string) (string, error) {
if fromEnv != "" {
v := os.Getenv(fromEnv)
if v == "" {
return "", fmt.Errorf("environment variable %s is not set or empty", fromEnv)
}
return v, nil
}

if fromFlag != "" {
return fromFlag, nil
}

info, err := os.Stdin.Stat()
if err != nil {
return "", err
}

if info.Mode()&os.ModeCharDevice != 0 {
fmt.Fprintln(os.Stderr, "Enter secret value, then press Ctrl+D:")
}

reader := bufio.NewReader(os.Stdin)
var b strings.Builder

for {
line, err := reader.ReadString('\n')
b.WriteString(line)
if err != nil {
if errors.Is(err, io.EOF) {
break
}
return "", err
}
}

value := strings.TrimRight(b.String(), "\r\n")
if value == "" {
return "", errors.New("secret value is empty")
}
return value, nil
}

func setRepoSecret(apiBase, token, owner, repo, name, value string) error {
pubKey, err := getRepoPublicKey(apiBase, token, owner, repo)
if err != nil {
return fmt.Errorf("get repo public key: %w", err)
}

encrypted, err := encryptWithPublicKey(pubKey.Key, value)
if err != nil {
return fmt.Errorf("encrypt secret: %w", err)
}

return putRepoSecret(apiBase, token, owner, repo, name, pubKey.ID, encrypted)
}

func getRepoPublicKey(apiBase, token, owner, repo string) (*repoPublicKey, error) {
Comment thread
mnkiefer marked this conversation as resolved.
Outdated
endpoint := fmt.Sprintf("%s/repos/%s/%s/actions/secrets/public-key", apiBase, owner, repo)

req, err := http.NewRequest(http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
addGitHubHeaders(req, token)

resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("GitHub API %s: %s", resp.Status, string(body))
}

var key repoPublicKey
if err := json.NewDecoder(resp.Body).Decode(&key); err != nil {
return nil, err
}
if key.ID == "" || key.Key == "" {
return nil, errors.New("public key response missing key_id or key")
}
return &key, nil
}

func encryptWithPublicKey(publicKeyB64, plaintext string) (string, error) {
raw, err := base64.StdEncoding.DecodeString(publicKeyB64)
if err != nil {
return "", fmt.Errorf("decode public key: %w", err)
}
if len(raw) != 32 {
return "", fmt.Errorf("unexpected public key length: %d", len(raw))
}

var pk [32]byte
copy(pk[:], raw)

ciphertext, err := box.SealAnonymous(nil, []byte(plaintext), &pk, rand.Reader)
if err != nil {
return "", fmt.Errorf("nacl encryption failed: %w", err)
}

return base64.StdEncoding.EncodeToString(ciphertext), nil
}

func putRepoSecret(apiBase, token, owner, repo, name, keyID, encryptedValue string) error {
endpoint := fmt.Sprintf("%s/repos/%s/%s/actions/secrets/%s",
apiBase, owner, repo, url.PathEscape(name))

body, err := json.Marshal(secretPayload{
EncryptedValue: encryptedValue,
KeyID: keyID,
})
if err != nil {
return err
}

req, err := http.NewRequest(http.MethodPut, endpoint, strings.NewReader(string(body)))
Comment thread
mnkiefer marked this conversation as resolved.
Outdated
if err != nil {
return err
}
addGitHubHeaders(req, token)
req.Header.Set("Content-Type", "application/json")

resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusCreated {
b, _ := io.ReadAll(resp.Body)
return fmt.Errorf("GitHub API %s: %s", resp.Status, string(b))
}

return nil
}

func addGitHubHeaders(req *http.Request, token string) {
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("Authorization", "Bearer "+token)
if req.Header.Get("X-GitHub-Api-Version") == "" {
req.Header.Set("X-GitHub-Api-Version", "2022-11-28")
}
}
30 changes: 30 additions & 0 deletions pkg/cli/tokens.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package cli

import (
"github.com/githubnext/gh-aw/pkg/logger"
"github.com/spf13/cobra"
)

var tokensCommandLog = logger.New("cli:tokens")

// NewTokensCommand creates the main tokens command with subcommands
func NewTokensCommand() *cobra.Command {
tokensCommandLog.Print("Creating tokens command with subcommands")
cmd := &cobra.Command{
Use: "tokens",
Short: "Inspect and bootstrap GitHub tokens for gh-aw",
Long: `Token utilities for GitHub Agentic Workflows.

Use this command to check which recommended secrets are configured
for the current repository and to see how to create them with
minimum required permissions.`,
RunE: func(cmd *cobra.Command, args []string) error {
return cmd.Help()
},
}

// Add subcommands
cmd.AddCommand(NewTokensBootstrapSubcommand())

return cmd
}
Loading
Loading