Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
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
5 changes: 5 additions & 0 deletions .changeset/patch-use-existing-copilot-token-add-wizard.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 5 additions & 3 deletions docs/src/content/docs/setup/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -191,16 +191,18 @@ The engine chosen at `init` time does not restrict workflows: every workflow sel

Add a workflow with interactive guided setup. Checks requirements, adds the markdown file, and generates the compiled YAML. Prompts for missing API keys and secrets. For remote workflows, this command follows frontmatter [`redirect`](/gh-aw/reference/frontmatter/#redirect-redirect) declarations before installation.

Before the final pull request confirmation, the wizard optionally offers to add repository support files for using coding agents to author, debug, update, and audit agentic workflows. The prompt is skipped when those support files are already configured. Declining adds only the selected workflow files.

```bash wrap
gh aw add-wizard githubnext/agentics/ci-doctor # Interactive setup
gh aw add-wizard https://github.com/org/repo/blob/main/workflows/my-workflow.md
gh aw add-wizard https://example.com/workflows/my-workflow.json # Arbitrary URL (JSON workflow)
gh aw add-wizard githubnext/agentics/ci-doctor --no-secret # Skip secret prompt
```

**Options:** `--no-secret`, `--dir/-d`, `--engine/-e`, `--no-gitattributes`, `--no-stop-after`, `--stop-after`, `--append`, `--no-security-scanner`, `--no-config`
**Options:** `--no-secret`, `--dir/-d`, `--engine/-e`, `--gh-aw-ref`, `--no-gitattributes`, `--no-stop-after`, `--stop-after`, `--append`, `--no-security-scanner`, `--no-config`

When the Copilot engine is selected, the wizard prompts the user to choose an authentication method: organization billing via [`permissions.copilot-requests: write`](/gh-aw/reference/auth/#copilot-requests-write-permission) (no PAT required), or a [`COPILOT_GITHUB_TOKEN`](/gh-aw/reference/auth/#copilot_github_token) personal access token (a separate token from the default `GITHUB_TOKEN`, because the agent needs elevated Copilot API access that the ephemeral workflow token does not carry). On the PAT path, the wizard auto-opens a preconfigured fine-grained PAT creation page (prefilled token name, expiration, and Copilot Requests permission). The GitHub page still must be completed manually in the browser. Users may paste either an existing suitable fine-grained PAT or a newly created one into the masked CLI prompt, but reuse should be based on the token's properties: personal-account resource owner, repository access set to Public repositories, and Copilot Requests permission available. If `COPILOT_GITHUB_TOKEN` already exists, the wizard still asks for the token again because GitHub does not expose stored secret values for validation. The flow does not rely on the PAT display name in GitHub's token list. The pasted token is then validated and stored as a repository secret.
When the Copilot engine is selected, the wizard prompts the user to choose an authentication method: organization billing via [`permissions.copilot-requests: write`](/gh-aw/reference/auth/#copilot-requests-write-permission) (no PAT required), or a [`COPILOT_GITHUB_TOKEN`](/gh-aw/reference/auth/#copilot_github_token) personal access token (a separate token from the default `GITHUB_TOKEN`, because the agent needs elevated Copilot API access that the ephemeral workflow token does not carry). When `COPILOT_GITHUB_TOKEN` already exists, using it is the default; because GitHub does not expose stored secret values, the user asserts that it is a suitable fine-grained PAT with Copilot Requests permission. The alternative replacement path opens a preconfigured fine-grained PAT creation page, validates the pasted token, and updates the repository secret.

#### `add`

Expand All @@ -217,7 +219,7 @@ gh aw add https://example.com/workflows/my-workflow.md # Arbitrary
gh aw add https://example.com/workflows/my-workflow.json # Arbitrary HTTPS URL (JSON workflow definition)
```

**Options:** `--dir/-d`, `--create-pull-request`, `--no-gitattributes`, `--append`, `--no-security-scanner`, `--engine/-e`, `--force/-f`, `--name/-n`, `--no-stop-after`, `--stop-after`
**Options:** `--dir/-d`, `--create-pull-request`, `--no-gitattributes`, `--append`, `--no-security-scanner`, `--engine/-e`, `--force/-f`, `--gh-aw-ref`, `--name/-n`, `--no-stop-after`, `--stop-after`

Repository-level packages can declare an [`aw.yml` manifest](/gh-aw/reference/aw-yml-package-manifest/) at the repository root or in a nested package folder to define installable files, package `README.md`, schema compatibility, and minimum supported CLI versions.

Expand Down
71 changes: 61 additions & 10 deletions pkg/cli/add_command.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,12 +78,40 @@ type AddOptions struct {
NoStopAfter bool
StopAfter string
DisableSecurityScanner bool
// RepoSlug is the already-resolved target repository in owner/repo format.
Comment thread
dsyme marked this conversation as resolved.
// When set, PR creation avoids fetching the same repository metadata again.
RepoSlug string
// GhAwRef is the resolved github/gh-aw commit SHA used by compiled action references.
GhAwRef string
// AddCopilotRequestsPermission injects permissions.copilot-requests: write into
// the workflow frontmatter, enabling GitHub Actions token auth for Copilot.
// Set by the add-wizard when the user selects org-billing auth instead of a PAT.
AddCopilotRequestsPermission bool
// initializedFiles contains files created by add-wizard after its clean-tree check.
initializedFiles []string
addWizard *addWizardOptions
}

type addWizardOptions struct {
initializedFiles []addInitializedFile
workingTreePrevalidated bool
showInteractiveProgress bool
secretSource secretSource
skipSecret bool
disableGitHubAppPermissionInference bool
}

func (opts AddOptions) wizardInitializedPaths() []string {
if opts.addWizard == nil {
return nil
}
paths := make([]string, 0, len(opts.addWizard.initializedFiles))
for _, file := range opts.addWizard.initializedFiles {
paths = append(paths, file.path)
}
return paths
}

func (opts AddOptions) showInteractiveProgress() bool {
return opts.addWizard != nil && opts.addWizard.showInteractiveProgress
}

// AddWorkflowsResult contains the result of adding workflows
Expand Down Expand Up @@ -131,6 +159,11 @@ func runAddCommand(cmd *cobra.Command, args []string, validateEngine func(string
noStopAfter, _ := cmd.Flags().GetBool("no-stop-after")
stopAfter, _ := cmd.Flags().GetString("stop-after")
disableSecurityScanner := resolveDeprecatedBoolFlag(cmd, "no-security-scanner", "disable-security-scanner")
ghAwRef, _ := cmd.Flags().GetString("gh-aw-ref")
resolvedGhAwRef, err := resolveAddGhAwRef(cmd.Context(), ghAwRef)
if err != nil {
return err
}

if nameFlag != "" && len(args) > 1 {
return errors.New("--name was set while multiple workflows were provided. Expected --name only with a single workflow source. Example: gh aw add githubnext/agentics/daily-repo-status --name daily-repo-status")
Expand All @@ -151,6 +184,7 @@ func runAddCommand(cmd *cobra.Command, args []string, validateEngine func(string
NoStopAfter: noStopAfter,
StopAfter: stopAfter,
DisableSecurityScanner: disableSecurityScanner,
GhAwRef: resolvedGhAwRef,
}
resolved, err := ResolveWorkflows(cmd.Context(), args, verbose)
if err != nil {
Expand Down Expand Up @@ -221,6 +255,8 @@ func registerAddCommandFlags(cmd *cobra.Command) {
// Add no-security-scanner flag to add command (--disable-security-scanner is kept as a deprecated alias)
addSecurityScannerFlag(cmd)

cmd.Flags().String("gh-aw-ref", "", "Pin compiled workflows to a branch, tag, or commit SHA of github/gh-aw; branch and tag names are resolved to an immutable full SHA")

// Register completions for add command
RegisterEngineFlagCompletion(cmd)
RegisterDirFlagCompletion(cmd, "dir")
Expand Down Expand Up @@ -264,8 +300,10 @@ func AddResolvedWorkflows(ctx context.Context, workflowStrings []string, resolve
}

// Check no other changes are present
if err := checkCleanWorkingDirectoryIgnoring(opts.Verbose, opts.initializedFiles); err != nil {
return nil, fmt.Errorf("working directory is not clean: %w", err)
if opts.addWizard == nil || !opts.addWizard.workingTreePrevalidated {
if err := checkCleanWorkingDirectoryIgnoring(opts.Verbose, opts.wizardInitializedPaths()); err != nil {
return nil, fmt.Errorf("working directory is not clean: %w", err)
}
}
}

Expand Down Expand Up @@ -436,9 +474,11 @@ func addWorkflowWithTracking(ctx context.Context, resolved *ResolvedWorkflow, tr

destFile := filepath.Join(githubWorkflowsDir, workflowName+".md")
fileExists := fileutil.FileExists(destFile)
if fileExists {
if fileExists && !opts.showInteractiveProgress() {
fmt.Fprintln(os.Stderr, console.FormatInfoMessage("Overwriting existing file: "+destFile))
}
stopProgress := startAddInteractiveProgress(opts, "Preparing workflow files...")
defer stopProgress()
workflowSpec = resolvedWorkflowSpec(workflowSpec, sourceInfo)
content, err := processWorkflowContentModifications(string(sourceContent), workflowSpec, sourceInfo, githubWorkflowsDir, opts)
if err != nil {
Expand All @@ -451,6 +491,15 @@ func addWorkflowWithTracking(ctx context.Context, resolved *ResolvedWorkflow, tr
return nil
}

func startAddInteractiveProgress(opts AddOptions, message string) func() {
if !opts.showInteractiveProgress() {
return func() {}
}
spinner := console.NewSpinner(message)
spinner.Start()
return spinner.Stop
}

func reportAddWorkflowStart(workflowSpec *WorkflowSpec, sourceContent []byte, opts AddOptions) {
addLog.Printf("Adding workflow: name=%s, content_size=%d bytes", workflowSpec.WorkflowName, len(sourceContent))
if !opts.Verbose {
Expand Down Expand Up @@ -538,23 +587,23 @@ func compileAddedWorkflow(ctx context.Context, destFile string, workflowSpec *Wo
// .lock.yml. The dispatch-workflow validator requires every .md dispatch target to be
// compiled before the main workflow can be validated. With --force, always recompile
// to pick up freshly overwritten worker files.
compileDispatchWorkflowDependencies(ctx, destFile, opts.Verbose, opts.Quiet, opts.EngineOverride, opts.Force, tracker)
compileDispatchWorkflowDependenciesWithActionRef(ctx, destFile, opts.Verbose, opts.Quiet, opts.EngineOverride, opts.GhAwRef, opts.Force, tracker)
// Compile any call-workflow .md worker dependencies that were just fetched and lack a
// .lock.yml. Errors are propagated: a missing worker .lock.yml would leave the
// orchestrator referencing a non-existent file. With --force, always recompile to
// pick up freshly overwritten worker files.
if err := compileCallWorkflowDependencies(ctx, destFile, opts.Verbose, opts.Quiet, opts.EngineOverride, opts.Force, tracker); err != nil {
if err := compileCallWorkflowDependenciesWithActionRef(ctx, destFile, opts.Verbose, opts.Quiet, opts.EngineOverride, opts.GhAwRef, opts.Force, tracker); err != nil {
printCompilationError(err, opts.Quiet)
return
}
// Compile the workflow
if tracker != nil {
if err := compileWorkflowWithTracking(ctx, destFile, opts.Verbose, opts.Quiet, opts.EngineOverride, tracker); err != nil {
if err := compileWorkflowWithTrackingAndActionRef(ctx, destFile, opts.Verbose, opts.Quiet, opts.EngineOverride, opts.GhAwRef, tracker); err != nil {
printCompilationError(err, opts.Quiet)
}
return
}
if err := compileWorkflow(ctx, destFile, opts.Verbose, opts.Quiet, opts.EngineOverride); err != nil {
if err := compileWorkflowWithActionRef(ctx, destFile, opts.Verbose, opts.Quiet, opts.EngineOverride, opts.GhAwRef); err != nil {
printCompilationError(err, opts.Quiet)
}
}
Expand Down Expand Up @@ -825,7 +874,9 @@ func addActionWorkflowWithTracking(resolved *ResolvedWorkflow, tracker *FileTrac
}
return fmt.Errorf("action workflow '%s' already exists in %s. Use --force to overwrite", workflowName+".yml", githubWorkflowsDir)
}
fmt.Fprintln(os.Stderr, console.FormatInfoMessage("Overwriting existing file: "+destFile))
if !opts.showInteractiveProgress() {
fmt.Fprintln(os.Stderr, console.FormatInfoMessage("Overwriting existing file: "+destFile))
}
}

if tracker != nil {
Expand Down
117 changes: 117 additions & 0 deletions pkg/cli/add_command_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,38 @@ func TestNewAddCommand(t *testing.T) {
// Check stop-after flag
stopAfterFlag := flags.Lookup("stop-after")
assert.NotNil(t, stopAfterFlag, "Should have 'stop-after' flag")

ghAwRefFlag := flags.Lookup("gh-aw-ref")
assert.NotNil(t, ghAwRefFlag, "Should have 'gh-aw-ref' flag")
}

func TestResolveAddGhAwRef_FullSHA(t *testing.T) {
t.Parallel()
const sha = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
resolved, err := resolveAddGhAwRef(context.Background(), sha)
require.NoError(t, err)
assert.Equal(t, sha, resolved)
}

func TestCompileWorkflowWithActionRef(t *testing.T) {
const sha = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
tmpDir := t.TempDir()
require.NoError(t, initTestGitRepo(tmpDir))
workflowFile := filepath.Join(tmpDir, ".github", "workflows", "pinned.md")
require.NoError(t, os.MkdirAll(filepath.Dir(workflowFile), 0o755))
require.NoError(t, os.WriteFile(workflowFile, []byte(`---
on: workflow_dispatch
permissions:
contents: read
---

# Pinned workflow
`), 0o644))

require.NoError(t, compileWorkflowWithActionRef(context.Background(), workflowFile, false, true, "", sha))
lockContent, err := os.ReadFile(filepath.Join(tmpDir, ".github", "workflows", "pinned.lock.yml"))
require.NoError(t, err)
assert.Contains(t, string(lockContent), "github/gh-aw/actions/setup@"+sha)
}

func TestNewAddCommand_MentionsEnterpriseSourceResolution(t *testing.T) {
Expand Down Expand Up @@ -524,6 +556,91 @@ func TestEnsureAddRepositoryInitializedWithDetails_AbsolutePaths(t *testing.T) {
require.Equal(t, filepath.Join(repoDir, filepath.FromSlash(writtenMarker)), files[0])
}

func TestConfirmAndInitializeAddRepository(t *testing.T) {
originalFindGitRoot := addFindGitRoot
originalInitRepository := addInitRepository
originalMissingInitMarkers := addMissingInitMarkers
originalConfirmAuthoringSupport := addConfirmAuthoringSupport
t.Cleanup(func() {
addFindGitRoot = originalFindGitRoot
addInitRepository = originalInitRepository
addMissingInitMarkers = originalMissingInitMarkers
addConfirmAuthoringSupport = originalConfirmAuthoringSupport
})

repoDir := t.TempDir()
addFindGitRoot = func() (string, error) { return repoDir, nil }

t.Run("already initialized skips confirmation", func(t *testing.T) {
addMissingInitMarkers = func(string, string) ([]string, error) { return nil, nil }
addConfirmAuthoringSupport = func(context.Context) (bool, error) {
t.Fatal("confirmation should not be shown when all support files exist")
return false, nil
}
addInitRepository = func(InitOptions) error {
t.Fatal("initialization should not run when all support files exist")
return nil
}

files, err := confirmAndInitializeAddRepository(context.Background(), "copilot", false, false)
require.NoError(t, err)
assert.Empty(t, files)
})

t.Run("declining creates no support files", func(t *testing.T) {
addMissingInitMarkers = func(string, string) ([]string, error) {
return []string{bootstrapAgenticSkillPath}, nil
}
addConfirmAuthoringSupport = func(context.Context) (bool, error) { return false, nil }
addInitRepository = func(InitOptions) error {
t.Fatal("initialization should not run after confirmation is declined")
return nil
}

files, err := confirmAndInitializeAddRepository(context.Background(), "copilot", false, false)
require.NoError(t, err)
assert.Empty(t, files)
})

t.Run("accepting quietly initializes support files", func(t *testing.T) {
marker := ".vscode/settings.json"
addMissingInitMarkers = func(string, string) ([]string, error) { return []string{marker}, nil }
addConfirmAuthoringSupport = func(context.Context) (bool, error) { return true, nil }
addInitRepository = func(opts InitOptions) error {
assert.True(t, opts.Quiet)
assert.Equal(t, "copilot", opts.Engine)
path := filepath.Join(repoDir, filepath.FromSlash(marker))
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0755))
return os.WriteFile(path, []byte(`{}`), 0644)
}

files, err := confirmAndInitializeAddRepository(context.Background(), "copilot", false, false)
require.NoError(t, err)
require.Equal(t, []addInitializedFile{{
path: filepath.Join(repoDir, filepath.FromSlash(marker)), displayPath: marker,
}}, files)
})

t.Run("preserves original contents for stale support files", func(t *testing.T) {
marker := ".vscode/settings.json"
markerPath := filepath.Join(repoDir, filepath.FromSlash(marker))
require.NoError(t, os.WriteFile(markerPath, []byte("original"), 0644))
addMissingInitMarkers = func(string, string) ([]string, error) { return []string{marker}, nil }
addConfirmAuthoringSupport = func(context.Context) (bool, error) { return true, nil }
addInitRepository = func(InitOptions) error {
return os.WriteFile(markerPath, []byte("updated"), 0644)
}

plan, err := confirmAddRepositoryInitialization(context.Background(), "copilot", false)
require.NoError(t, err)
files, err := applyAddRepositoryInitialization(plan, "copilot", false, false)
require.NoError(t, err)
require.Equal(t, []addInitializedFile{{
path: markerPath, displayPath: marker, wasExisting: true, originalContent: []byte("original"),
}}, files)
})
}

func TestAddResolvedWorkflows_IgnoresBootstrapRequireOwnerTypeDuringInstall(t *testing.T) {
originalCheckOwnerType := bootstrapCheckOwnerType
t.Cleanup(func() {
Expand Down
19 changes: 19 additions & 0 deletions pkg/cli/add_gh_aw_ref.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package cli

import (
"context"
"fmt"

"github.com/github/gh-aw/pkg/workflow"
)

func resolveAddGhAwRef(ctx context.Context, ref string) (string, error) {
if ref == "" {
return "", nil
}
resolvedRef, err := workflow.ResolveGhAwRef(ctx, ref)
if err != nil {
return "", fmt.Errorf("--gh-aw-ref: %w", err)
}
return resolvedRef, nil
}
Loading
Loading