Skip to content
Closed
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
36 changes: 36 additions & 0 deletions docs/guides/getting-started/configuring-github.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,42 @@ The goal of this document is that you configure Fullsend for your GitHub reposit
* Download the latest [fullsend](https://github.com/fullsend-ai/fullsend/releases) CLI.
* Download the latest [gh](https://cli.github.com/) CLI and authenticate with it.

### Token resolution

The fullsend CLI resolves GitHub credentials in the following order:

1. **`GH_TOKEN`** environment variable
2. **`GITHUB_TOKEN`** environment variable
3. **`gh auth token`** (from `gh auth login`)

The first non-empty value wins. If your organization restricts token types
(see below), export a fine-grained PAT as `GH_TOKEN` to override the default
`gh` credential.

### Organizations that restrict classic PATs

Some GitHub organizations enforce a policy that blocks classic personal access
tokens. When this policy is active, the default token from `gh auth login`
(which is a classic PAT) will be rejected with a 403 error.

To work with these organizations, create a **fine-grained personal access
token** and export it as `GH_TOKEN`:

1. Go to <https://github.com/settings/personal-access-tokens/new>
2. Scope the token to your target organization
3. Grant the following **repository permissions**:

| Permission | Level | Why |
|---|---|---|
| Contents | Read and write | Commits `.fullsend/config.yaml` and scaffold files |
| Workflows | Read and write | Writes/updates files under `.github/workflows/` |
| Secrets | Read and write | Sets `FULLSEND_GCP_PROJECT_ID` / `FULLSEND_GCP_WIF_PROVIDER` |
| Variables | Read and write | Sets `FULLSEND_MINT_URL` / `FULLSEND_GCP_REGION` |
| Metadata | Read-only | GitHub-required baseline (automatically selected) |
| Pull requests | Read and write | Only needed without `--direct` |

4. Export the token: `export GH_TOKEN='github_pat_...'`

## Installing GitHub Applications

In order to use Fullsend install the following applications to your organization
Expand Down
57 changes: 49 additions & 8 deletions internal/cli/admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,31 @@ func resolveToken() (string, error) {
return "", fmt.Errorf("no GitHub token found: set GH_TOKEN, GITHUB_TOKEN, or run 'gh auth login'")
}

// classicPATHint wraps err with remediation guidance when the
// organization blocks classic personal access tokens. For any other
// error it returns err unchanged.
func classicPATHint(err error) error {
if !forge.IsClassicPATForbidden(err) {
return err
}
return fmt.Errorf("%w\n\n"+
"This organization requires a fine-grained personal access token.\n"+
"The fullsend CLI resolves credentials in this order:\n"+
" 1. GH_TOKEN environment variable\n"+
" 2. GITHUB_TOKEN environment variable\n"+
" 3. gh auth token (from 'gh auth login')\n\n"+
"To fix this:\n"+
" 1. Create a fine-grained PAT at https://github.com/settings/personal-access-tokens/new\n"+
" 2. Scope it to your organization and grant these repository permissions:\n"+
" Contents: Read and write\n"+
" Workflows: Read and write\n"+
" Secrets: Read and write\n"+
" Variables: Read and write\n"+
" Metadata: Read-only (automatically selected)\n"+
" Pull requests: Read and write (only needed without --direct)\n"+
" 3. Export it: export GH_TOKEN='github_pat_...'", err)
}

// validateOrgName checks that org is a valid GitHub organization name.
func validateOrgName(org string) error {
if org == "" {
Expand Down Expand Up @@ -433,7 +458,7 @@ Inference authentication:
// Discover all org repos upfront to avoid redundant API calls in runDryRun/runInstall.
allRepos, err := client.ListOrgRepos(ctx, org)
if err != nil {
return fmt.Errorf("listing org repos: %w", err)
return classicPATHint(fmt.Errorf("listing org repos: %w", err))
}

var repos []string
Expand Down Expand Up @@ -1021,7 +1046,7 @@ func applyPerRepoScaffold(ctx context.Context, client forge.Client, printer *ui.

targetRepo, err := client.GetRepo(ctx, owner, repo)
if err != nil {
return fmt.Errorf("getting repo info: %w", err)
return classicPATHint(fmt.Errorf("getting repo info: %w", err))
}
commitMsg := fmt.Sprintf("chore: initialize fullsend-%s per-repo installation", version)
printer.StepStart(fmt.Sprintf("Committing scaffold files to %s/%s (%s branch)",
Expand Down Expand Up @@ -1163,7 +1188,7 @@ func runDryRun(ctx context.Context, client forge.Client, printer *ui.Printer, or
} else {
allRepos, err = client.ListOrgRepos(ctx, org)
if err != nil {
return fmt.Errorf("listing org repos: %w", err)
return classicPATHint(fmt.Errorf("listing org repos: %w", err))
}
printer.StepDone(fmt.Sprintf("Found %d repositories", len(allRepos)))
}
Expand Down Expand Up @@ -1429,7 +1454,7 @@ func ensureConfigRepoExists(ctx context.Context, client forge.Client, printer *u
return nil
}
if !forge.IsNotFound(err) {
return fmt.Errorf("checking for config repo: %w", err)
return classicPATHint(fmt.Errorf("checking for config repo: %w", err))
}

printer.StepStart("Creating " + forge.ConfigRepoName + " repository")
Expand All @@ -1441,7 +1466,7 @@ func ensureConfigRepoExists(ctx context.Context, client forge.Client, printer *u
return nil
}
printer.StepFail("Failed to create " + forge.ConfigRepoName + " repository")
return fmt.Errorf("creating config repo: %w", err)
return classicPATHint(fmt.Errorf("creating config repo: %w", err))
}
printer.StepDone("Created " + forge.ConfigRepoName + " repository")
return nil
Expand Down Expand Up @@ -1492,7 +1517,7 @@ func runInstall(ctx context.Context, client forge.Client, printer *ui.Printer, o
printer.Header("Discovering repositories")
allRepos, err = client.ListOrgRepos(ctx, org)
if err != nil {
return fmt.Errorf("listing org repos: %w", err)
return classicPATHint(fmt.Errorf("listing org repos: %w", err))
}
printer.StepDone(fmt.Sprintf("Found %d repositories", len(allRepos)))
}
Expand Down Expand Up @@ -1785,7 +1810,7 @@ func runUninstall(ctx context.Context, client forge.Client, printer *ui.Printer,
func runAnalyze(ctx context.Context, client forge.Client, printer *ui.Printer, org string) error {
allRepos, err := client.ListOrgRepos(ctx, org)
if err != nil {
return fmt.Errorf("listing org repos: %w", err)
return classicPATHint(fmt.Errorf("listing org repos: %w", err))
}

repoNames := repoNameList(allRepos)
Expand Down Expand Up @@ -2192,6 +2217,10 @@ func runEnableRepos(ctx context.Context, client forge.Client, printer *ui.Printe
printer.StepStart("Discovering all organization repositories")
allOrgRepos, err = client.ListOrgRepos(ctx, org)
if err != nil {
if forge.IsClassicPATForbidden(err) {
printer.StepFail("Classic PAT rejected by organization policy")
return classicPATHint(fmt.Errorf("listing org repos: %w", err))
}
printer.StepFail("Failed to list organization repositories")
printer.StepInfo("Hint: verify your token has 'repo' scope with: gh auth refresh -s repo")
return fmt.Errorf("listing org repos: %w", err)
Expand All @@ -2211,6 +2240,10 @@ func runEnableRepos(ctx context.Context, client forge.Client, printer *ui.Printe

allOrgRepos, err = client.ListOrgRepos(ctx, org)
if err != nil {
if forge.IsClassicPATForbidden(err) {
printer.StepFail("Classic PAT rejected by organization policy")
return classicPATHint(fmt.Errorf("listing org repos: %w", err))
}
printer.StepFail("Failed to list organization repositories")
printer.StepInfo("Hint: verify your token has 'repo' scope with: gh auth refresh -s repo")
return fmt.Errorf("listing org repos: %w", err)
Expand Down Expand Up @@ -2443,7 +2476,11 @@ func runDisableRepos(ctx context.Context, client forge.Client, printer *ui.Print
if cfg.Dispatch.Mode == "oidc-mint" {
allOrgRepos, listErr := client.ListOrgRepos(ctx, org)
if listErr != nil {
printer.StepWarn(fmt.Sprintf("could not list org repos for variable sync: %v", listErr))
if forge.IsClassicPATForbidden(listErr) {
printer.StepWarn("Classic PAT rejected; skipping variable visibility sync")
} else {
printer.StepWarn(fmt.Sprintf("could not list org repos for variable sync: %v", listErr))
}
} else {
syncOrgVariableVisibility(ctx, client, printer, org, cfg, allOrgRepos)
}
Expand Down Expand Up @@ -2479,6 +2516,10 @@ func loadRepoConfig(ctx context.Context, client forge.Client, printer *ui.Printe
printer.StepFail(".fullsend repository not found")
return nil, fmt.Errorf(".fullsend repository not found: run 'fullsend admin install %s' first", org)
}
if forge.IsClassicPATForbidden(err) {
printer.StepFail("Classic PAT rejected by organization policy")
return nil, classicPATHint(fmt.Errorf("checking .fullsend repository: %w", err))
}
printer.StepFail("Failed to check .fullsend repository")
printer.StepInfo("Hint: verify your token has 'repo' scope with: gh auth refresh -s repo")
return nil, fmt.Errorf("checking .fullsend repository: %w", err)
Expand Down
16 changes: 16 additions & 0 deletions internal/cli/admin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2235,3 +2235,19 @@ func TestApplyPerRepoScaffold_ProtectedBranch_BranchUpToDate(t *testing.T) {

assert.Contains(t, buf.String(), "up to date")
}

func TestClassicPATHint(t *testing.T) {
t.Run("wraps classic PAT error with remediation", func(t *testing.T) {
inner := fmt.Errorf("listing org repos: %w", forge.ErrClassicPATForbidden)
got := classicPATHint(inner)
assert.ErrorIs(t, got, forge.ErrClassicPATForbidden)
assert.Contains(t, got.Error(), "fine-grained personal access token")
assert.Contains(t, got.Error(), "GH_TOKEN")
})

t.Run("passes through non-PAT error unchanged", func(t *testing.T) {
inner := fmt.Errorf("listing org repos: connection refused")
got := classicPATHint(inner)
assert.Equal(t, inner, got)
})
}
12 changes: 8 additions & 4 deletions internal/cli/github.go
Original file line number Diff line number Diff line change
Expand Up @@ -351,7 +351,7 @@ func runGitHubSetupPerOrg(ctx context.Context, client forge.Client, printer *ui.

allRepos, err := client.ListOrgRepos(ctx, org)
if err != nil {
return fmt.Errorf("listing org repos: %w", err)
return classicPATHint(fmt.Errorf("listing org repos: %w", err))
}

repoNames := repoNameList(allRepos)
Expand Down Expand Up @@ -701,7 +701,7 @@ func runGitHubStatus(ctx context.Context, client forge.Client, printer *ui.Print
printer.StepInfo("Run 'fullsend github setup " + org + "' to configure")
return nil
}
return fmt.Errorf("checking config repo: %w", err)
return classicPATHint(fmt.Errorf("checking config repo: %w", err))
}
printer.StepDone(forge.ConfigRepoName + " repository exists")

Expand Down Expand Up @@ -845,7 +845,7 @@ func runGitHubUninstall(ctx context.Context, client forge.Client, printer *ui.Pr
if forge.IsNotFound(err) {
printer.StepInfo(forge.ConfigRepoName + " repository already deleted")
} else {
return fmt.Errorf("checking for config repo: %w", err)
return classicPATHint(fmt.Errorf("checking for config repo: %w", err))
}
} else {
printer.StepStart("Deleting " + forge.ConfigRepoName + " repository")
Expand Down Expand Up @@ -916,7 +916,11 @@ func runGitHubUninstall(ctx context.Context, client forge.Client, printer *ui.Pr
}
} else {
// Can't check — fall back to showing all of them.
printer.StepWarn("Could not verify which apps exist; showing all")
if forge.IsClassicPATForbidden(listErr) {
printer.StepWarn("Classic PAT rejected by organization policy; showing all apps")
} else {
printer.StepWarn("Could not verify which apps exist; showing all")
}
existingSlugs = agentSlugs
}
if len(existingSlugs) > 0 {
Expand Down
11 changes: 11 additions & 0 deletions internal/forge/forge.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,17 @@ func IsBranchProtected(err error) bool {
return errors.Is(err, ErrBranchProtected)
}

// ErrClassicPATForbidden indicates that the GitHub organization has a
// policy that blocks classic personal access tokens. The user must
// create a fine-grained PAT with the required repository permissions.
var ErrClassicPATForbidden = errors.New("classic PAT forbidden by organization policy")

// IsClassicPATForbidden reports whether err indicates that a classic
// personal access token was rejected by an organization policy.
func IsClassicPATForbidden(err error) bool {
return errors.Is(err, ErrClassicPATForbidden)
}

// Repository represents a repository on a git forge.
type Repository struct {
ID int64
Expand Down
18 changes: 18 additions & 0 deletions internal/forge/github/github.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,9 @@ func (e *APIError) Unwrap() error {
if e.StatusCode == http.StatusUnprocessableEntity && isAlreadyExistsError(e) {
return forge.ErrAlreadyExists
}
if isClassicPATForbiddenError(e) {
return forge.ErrClassicPATForbidden
}
return nil
}

Expand Down Expand Up @@ -804,6 +807,21 @@ func isAlreadyExistsError(apiErr *APIError) bool {
return strings.Contains(msg, "already exists")
}

// isClassicPATForbiddenError returns true when the API error indicates
// that the organization blocks classic personal access tokens. GitHub
// returns a 403 with a message containing "forbids access via a
// personal access token" in this case.
func isClassicPATForbiddenError(apiErr *APIError) bool {
if apiErr.StatusCode != http.StatusForbidden {
return false
}
msg := strings.ToLower(apiErr.Message)
for _, d := range apiErr.Errors {
msg += " " + strings.ToLower(d.Message)
}
return strings.Contains(msg, "forbids access via a personal access token")
}

// blobSHA computes the Git blob object SHA-1 for the given content.
func blobSHA(content []byte) string {
h := sha1.New()
Expand Down
55 changes: 55 additions & 0 deletions internal/forge/github/github_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -776,6 +776,56 @@ func TestIsAlreadyExistsError(t *testing.T) {
}
}

func TestIsClassicPATForbiddenError(t *testing.T) {
tests := []struct {
name string
apiErr *APIError
want bool
}{
{
name: "classic PAT forbidden by org policy",
apiErr: &APIError{StatusCode: 403, Message: "acme-corp forbids access via a personal access token (classic). Please use a GitHub App, OAuth App, or a personal access token with fine-grained permissions."},
want: true,
},
{
name: "classic PAT forbidden lowercase org name",
apiErr: &APIError{StatusCode: 403, Message: "my-org forbids access via a personal access token (classic)"},
want: true,
},
{
name: "generic 403 resource not accessible",
apiErr: &APIError{StatusCode: 403, Message: "Resource not accessible by integration"},
want: false,
},
{
name: "secondary rate limit 403",
apiErr: &APIError{StatusCode: 403, Message: "You have exceeded a secondary rate limit"},
want: false,
},
{
name: "classic PAT forbidden in Errors array only",
apiErr: &APIError{
StatusCode: 403,
Message: "Forbidden",
Errors: []APIErrorDetail{
{Message: "acme-corp forbids access via a personal access token (classic)"},
},
},
want: true,
},
{
name: "404 not found is not a PAT error",
apiErr: &APIError{StatusCode: 404, Message: "Not Found"},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, isClassicPATForbiddenError(tt.apiErr))
})
}
}

func TestAPIError_Unwrap(t *testing.T) {
tests := []struct {
name string
Expand Down Expand Up @@ -814,6 +864,11 @@ func TestAPIError_Unwrap(t *testing.T) {
apiErr: &APIError{StatusCode: 403, Message: "Resource not accessible by integration"},
wantNil: true,
},
{
name: "403 classic PAT forbidden unwraps to ErrClassicPATForbidden",
apiErr: &APIError{StatusCode: 403, Message: "acme-corp forbids access via a personal access token (classic)"},
wantErr: forge.ErrClassicPATForbidden,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
Expand Down
Loading