Skip to content

Commit e79c532

Browse files
authored
Run package manifest config bootstrap flow by default in gh aw add (#46041)
1 parent 38e22c4 commit e79c532

25 files changed

Lines changed: 1074 additions & 1977 deletions

cmd/gh-aw/main.go

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -697,9 +697,6 @@ Use "` + string(constants.CLIExtensionPrefix) + ` help all" to show help for all
697697
// Create and setup add-wizard command
698698
addWizardCmd := cli.NewAddWizardCommand(validateEngine)
699699

700-
// Create and setup bootstrap command
701-
bootstrapCmd := cli.NewBootstrapCommand(validateEngine)
702-
703700
// Create and setup update command
704701
updateCmd := cli.NewUpdateCommand(validateEngine)
705702

@@ -842,7 +839,6 @@ Use "` + string(constants.CLIExtensionPrefix) + ` help all" to show help for all
842839
newCmd.GroupID = "setup"
843840
addCmd.GroupID = "setup"
844841
addWizardCmd.GroupID = "setup"
845-
bootstrapCmd.GroupID = "setup"
846842
removeCmd.GroupID = "setup"
847843
updateCmd.GroupID = "setup"
848844
deployCmd.GroupID = "setup"
@@ -889,7 +885,6 @@ Use "` + string(constants.CLIExtensionPrefix) + ` help all" to show help for all
889885
// Add all commands to root
890886
rootCmd.AddCommand(addCmd)
891887
rootCmd.AddCommand(addWizardCmd)
892-
rootCmd.AddCommand(bootstrapCmd)
893888
rootCmd.AddCommand(updateCmd)
894889
rootCmd.AddCommand(deployCmd)
895890
rootCmd.AddCommand(upgradeCmd)

pkg/cli/add_command.go

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -156,15 +156,31 @@ func runAddCommand(cmd *cobra.Command, args []string, validateEngine func(string
156156
if err != nil {
157157
return err
158158
}
159-
if _, err := AddResolvedWorkflows(cmd.Context(), args, resolved, opts); err != nil {
159+
if err := rejectBootstrapProfileForRegularAdd(args, resolved.BootstrapProfile); err != nil {
160+
return err
161+
}
162+
if err := ensureAddRepositoryInitialized(engineOverride, verbose); err != nil {
160163
return err
161164
}
162-
if resolved.BootstrapProfile != nil {
163-
printBootstrapConfigTODO(cmd.ErrOrStderr(), resolved.BootstrapProfile)
165+
if _, err := AddResolvedWorkflows(cmd.Context(), args, resolved, opts); err != nil {
166+
return err
164167
}
165168
return nil
166169
}
167170

171+
func rejectBootstrapProfileForRegularAdd(sources []string, profile *resolvedBootstrapProfile) error {
172+
if profile == nil || profile.Profile == nil || len(profile.Profile.Config) == 0 {
173+
return nil
174+
}
175+
176+
requestedSources := strings.Join(sources, " ")
177+
if requestedSources == "" {
178+
requestedSources = profile.PackageID
179+
}
180+
181+
return fmt.Errorf("package %s declares aw.yml config and cannot be installed with 'gh aw add'. Use 'gh aw add-wizard %s' so the config steps can run interactively", profile.PackageID, requestedSources)
182+
}
183+
168184
func registerAddCommandFlags(cmd *cobra.Command) {
169185
// Add name flag to add command
170186
cmd.Flags().StringP("name", "n", "", "Specify name for the added workflow (without .md extension)")
@@ -390,7 +406,11 @@ func addWorkflowWithTracking(ctx context.Context, resolved *ResolvedWorkflow, tr
390406
if resolved.IsPackageAgentFile {
391407
return addAgentFileWithTracking(resolved, tracker, opts, gitRoot)
392408
}
393-
skip, err := validateWorkflowDestination(githubWorkflowsDir, workflowName, opts)
409+
sourceRepo := ""
410+
if sourceInfo != nil && !sourceInfo.IsLocal {
411+
sourceRepo = workflowSpec.RepoSlug
412+
}
413+
skip, err := validateWorkflowDestination(githubWorkflowsDir, workflowName, sourceRepo, opts)
394414
if err != nil {
395415
return err
396416
}
@@ -430,11 +450,18 @@ func reportAddWorkflowStart(workflowSpec *WorkflowSpec, sourceContent []byte, op
430450
fmt.Fprintln(os.Stderr, console.FormatSuccessMessage(fmt.Sprintf("Using pre-fetched workflow content (%d bytes)", len(sourceContent))))
431451
}
432452

433-
func validateWorkflowDestination(githubWorkflowsDir, workflowName string, opts AddOptions) (bool, error) {
453+
func validateWorkflowDestination(githubWorkflowsDir, workflowName, sourceRepo string, opts AddOptions) (bool, error) {
434454
existingFile := filepath.Join(githubWorkflowsDir, workflowName+".md")
435455
if !fileutil.FileExists(existingFile) || opts.Force {
436456
return false, nil
437457
}
458+
if sourceRepo != "" {
459+
existingSourceRepo := readSourceRepoFromFile(existingFile)
460+
if existingSourceRepo == sourceRepo {
461+
fmt.Fprintln(os.Stderr, console.FormatInfoMessage("Workflow from same source already exists, skipping: "+existingFile))
462+
return true, nil
463+
}
464+
}
438465
if opts.FromWildcard {
439466
fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Workflow '%s' already exists in .github/workflows/. Skipping.", workflowName)))
440467
return true, nil

pkg/cli/add_command_test.go

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"strings"
1111
"testing"
1212

13+
"github.com/github/gh-aw/pkg/gitutil"
1314
"github.com/github/gh-aw/pkg/testutil"
1415
"github.com/github/gh-aw/pkg/workflow"
1516
"github.com/spf13/cobra"
@@ -367,6 +368,198 @@ func TestAddCommandArgs(t *testing.T) {
367368
require.NoError(t, err, "Should not error with multiple arguments")
368369
}
369370

371+
func TestRejectBootstrapProfileForRegularAdd(t *testing.T) {
372+
profileWithConfig := &resolvedBootstrapProfile{
373+
PackageID: "githubnext/central-agentic-ops",
374+
Profile: &repositoryPackageBootstrap{
375+
Config: []repositoryPackageBootstrapAction{
376+
{Type: "repo-variable", Name: "EXAMPLE", Prompt: "Enter value"},
377+
},
378+
},
379+
}
380+
381+
t.Run("rejects regular add for packages with manifest config", func(t *testing.T) {
382+
err := rejectBootstrapProfileForRegularAdd([]string{"githubnext/central-agentic-ops"}, profileWithConfig)
383+
require.Error(t, err)
384+
assert.Contains(t, err.Error(), "package githubnext/central-agentic-ops declares aw.yml config")
385+
assert.Contains(t, err.Error(), "gh aw add-wizard githubnext/central-agentic-ops")
386+
})
387+
388+
t.Run("uses requested sources in the add-wizard guidance", func(t *testing.T) {
389+
err := rejectBootstrapProfileForRegularAdd([]string{"githubnext/central-agentic-ops", "./local-workflow.md"}, profileWithConfig)
390+
require.Error(t, err)
391+
assert.Contains(t, err.Error(), "gh aw add-wizard githubnext/central-agentic-ops ./local-workflow.md")
392+
})
393+
394+
t.Run("allows packages without manifest config", func(t *testing.T) {
395+
err := rejectBootstrapProfileForRegularAdd([]string{"owner/pkg"}, nil)
396+
require.NoError(t, err)
397+
398+
err = rejectBootstrapProfileForRegularAdd([]string{"owner/pkg"}, &resolvedBootstrapProfile{
399+
PackageID: "owner/pkg",
400+
Profile: &repositoryPackageBootstrap{Config: nil},
401+
})
402+
require.NoError(t, err)
403+
})
404+
}
405+
406+
func TestEnsureAddRepositoryInitialized(t *testing.T) {
407+
originalFindGitRoot := addFindGitRoot
408+
originalInitRepository := addInitRepository
409+
originalMissingInitMarkers := addMissingInitMarkers
410+
t.Cleanup(func() {
411+
addFindGitRoot = originalFindGitRoot
412+
addInitRepository = originalInitRepository
413+
addMissingInitMarkers = originalMissingInitMarkers
414+
})
415+
416+
t.Run("skips initialization outside a git checkout", func(t *testing.T) {
417+
addFindGitRoot = func() (string, error) { return "", gitutil.ErrNotGitRepository }
418+
addMissingInitMarkers = func(string, string) ([]string, error) {
419+
t.Fatal("missing init markers check should be skipped outside a git checkout")
420+
return nil, nil
421+
}
422+
addInitRepository = func(InitOptions) error {
423+
t.Fatal("InitRepository should not run outside a git checkout")
424+
return nil
425+
}
426+
427+
err := ensureAddRepositoryInitialized("", false)
428+
require.NoError(t, err)
429+
})
430+
431+
t.Run("runs init when required markers are missing", func(t *testing.T) {
432+
repoDir := t.TempDir()
433+
addFindGitRoot = func() (string, error) { return repoDir, nil }
434+
addMissingInitMarkers = func(baseDir string, engineOverride string) ([]string, error) {
435+
assert.Equal(t, ".", baseDir)
436+
assert.Equal(t, "claude", engineOverride)
437+
return []string{".gitattributes"}, nil
438+
}
439+
440+
called := false
441+
addInitRepository = func(opts InitOptions) error {
442+
called = true
443+
assert.True(t, opts.Verbose)
444+
assert.Equal(t, "claude", opts.Engine)
445+
assert.True(t, opts.Skill)
446+
assert.True(t, opts.Agent)
447+
assert.True(t, opts.MCP)
448+
assert.False(t, opts.CodespaceEnabled)
449+
assert.False(t, opts.Completions)
450+
assert.False(t, opts.CreatePR)
451+
return nil
452+
}
453+
454+
err := ensureAddRepositoryInitialized("claude", true)
455+
require.NoError(t, err)
456+
assert.True(t, called)
457+
})
458+
459+
t.Run("does nothing when markers are already present", func(t *testing.T) {
460+
repoDir := t.TempDir()
461+
addFindGitRoot = func() (string, error) { return repoDir, nil }
462+
addMissingInitMarkers = func(string, string) ([]string, error) { return nil, nil }
463+
addInitRepository = func(InitOptions) error {
464+
t.Fatal("InitRepository should not run when markers are already present")
465+
return nil
466+
}
467+
468+
err := ensureAddRepositoryInitialized("", false)
469+
require.NoError(t, err)
470+
})
471+
}
472+
473+
func TestAddResolvedWorkflows_IgnoresBootstrapRequireOwnerTypeDuringInstall(t *testing.T) {
474+
originalCheckOwnerType := bootstrapCheckOwnerType
475+
t.Cleanup(func() {
476+
bootstrapCheckOwnerType = originalCheckOwnerType
477+
})
478+
479+
bootstrapCheckOwnerType = func(context.Context, string) (string, error) { return "User", nil }
480+
481+
resolved := &ResolvedWorkflows{
482+
Workflows: nil,
483+
BootstrapProfile: &resolvedBootstrapProfile{
484+
PackageID: "githubnext/central-agentic-ops",
485+
Profile: &repositoryPackageBootstrap{
486+
Config: []repositoryPackageBootstrapAction{{Type: "require-owner-type", Value: "org"}},
487+
},
488+
},
489+
}
490+
491+
_, err := AddResolvedWorkflows(context.Background(), []string{"githubnext/central-agentic-ops"}, resolved, AddOptions{NoGitattributes: true})
492+
require.NoError(t, err)
493+
}
494+
495+
func TestCompileDispatchWorkflowDependencies_FallsBackToRawFrontmatter(t *testing.T) {
496+
tmpDir := testutil.TempDir(t, "dispatch-workflow-fallback-*")
497+
workflowsDir := setupMinimalGitRepo(t, tmpDir)
498+
499+
mainPath := filepath.Join(workflowsDir, "dispatcher.md")
500+
workerPath := filepath.Join(workflowsDir, "worker.md")
501+
502+
require.NoError(t, os.WriteFile(mainPath, []byte(`---
503+
name: Dispatcher
504+
on:
505+
workflow_dispatch:
506+
safe-outputs:
507+
dispatch-workflow:
508+
workflows: [worker]
509+
imports:
510+
- uses: shared/missing.md
511+
---
512+
513+
# Dispatcher
514+
`), 0o644))
515+
require.NoError(t, os.WriteFile(workerPath, []byte(`---
516+
name: Worker
517+
on:
518+
workflow_dispatch:
519+
---
520+
521+
# Worker
522+
`), 0o644))
523+
524+
compileDispatchWorkflowDependencies(context.Background(), mainPath, false, true, "", nil)
525+
526+
lockPath := filepath.Join(workflowsDir, "worker.lock.yml")
527+
_, err := os.Stat(lockPath)
528+
require.NoError(t, err, "dispatch dependency should still be compiled when merged parse fails")
529+
lockContent, err := os.ReadFile(lockPath)
530+
require.NoError(t, err)
531+
assert.Contains(t, string(lockContent), "name: \"Worker\"", "compiled dispatch dependency should preserve its workflow name")
532+
}
533+
534+
func TestValidateWorkflowDestination_SkipsExistingWorkflowFromSameSource(t *testing.T) {
535+
workflowsDir := t.TempDir()
536+
existingPath := filepath.Join(workflowsDir, "dependabot.md")
537+
require.NoError(t, os.WriteFile(existingPath, []byte(`---
538+
source: githubnext/central-agentic-ops/.github/workflows/dependabot.md@main
539+
---
540+
# Dependabot
541+
`), 0o644))
542+
543+
skip, err := validateWorkflowDestination(workflowsDir, "dependabot", "githubnext/central-agentic-ops", AddOptions{})
544+
require.NoError(t, err)
545+
assert.True(t, skip)
546+
}
547+
548+
func TestValidateWorkflowDestination_ErrorsForExistingWorkflowFromDifferentSource(t *testing.T) {
549+
workflowsDir := t.TempDir()
550+
existingPath := filepath.Join(workflowsDir, "dependabot.md")
551+
require.NoError(t, os.WriteFile(existingPath, []byte(`---
552+
source: octo/other/.github/workflows/dependabot.md@main
553+
---
554+
# Dependabot
555+
`), 0o644))
556+
557+
skip, err := validateWorkflowDestination(workflowsDir, "dependabot", "githubnext/central-agentic-ops", AddOptions{})
558+
require.Error(t, err)
559+
assert.False(t, skip)
560+
assert.Contains(t, err.Error(), "workflow 'dependabot' already exists")
561+
}
562+
370563
// TestAddMultipleWorkflowsNameFlag verifies that --name is not allowed when multiple workflows are specified.
371564
func TestAddMultipleWorkflowsNameFlag(t *testing.T) {
372565
cmd := NewAddCommand(validateEngineStub)

pkg/cli/add_init.go

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
package cli
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
7+
"github.com/github/gh-aw/pkg/gitutil"
8+
)
9+
10+
var addFindGitRoot = gitutil.FindGitRoot
11+
var addInitRepository = InitRepository
12+
var addMissingInitMarkers = missingBootstrapInitMarkers
13+
14+
func ensureAddRepositoryInitialized(engineOverride string, verbose bool) error {
15+
_, err := ensureAddRepositoryInitializedWithDetails(engineOverride, verbose)
16+
return err
17+
}
18+
19+
func ensureAddRepositoryInitializedWithDetails(engineOverride string, verbose bool) ([]string, error) {
20+
gitRoot, err := addFindGitRoot()
21+
if err != nil {
22+
if errors.Is(err, gitutil.ErrNotGitRepository) {
23+
addLog.Print("Skipping automatic repository initialization outside a git checkout")
24+
return nil, nil
25+
}
26+
return nil, fmt.Errorf("failed to determine repository root for automatic initialization: %w", err)
27+
}
28+
29+
var initializedFiles []string
30+
err = withWorkingDir(gitRoot, func() error {
31+
missingMarkers, err := addMissingInitMarkers(".", engineOverride)
32+
if err != nil {
33+
return fmt.Errorf("failed to inspect repository initialization state: %w", err)
34+
}
35+
if len(missingMarkers) == 0 {
36+
return nil
37+
}
38+
initializedFiles = append(initializedFiles, missingMarkers...)
39+
40+
addLog.Printf("Repository missing init markers; running init: %v", missingMarkers)
41+
if err := addInitRepository(InitOptions{
42+
Verbose: verbose,
43+
Engine: engineOverride,
44+
Skill: true,
45+
Agent: true,
46+
MCP: true,
47+
CodespaceRepos: []string{},
48+
CodespaceEnabled: false,
49+
Completions: false,
50+
CreatePR: false,
51+
}); err != nil {
52+
return fmt.Errorf("failed to initialize repository for agentic workflows: %w", err)
53+
}
54+
55+
return nil
56+
})
57+
if err != nil {
58+
return nil, err
59+
}
60+
61+
return initializedFiles, nil
62+
}

pkg/cli/add_interactive_orchestrator.go

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,8 +135,13 @@ func RunAddInteractive(ctx context.Context, config *AddInteractiveConfig) error
135135
return err
136136
}
137137

138+
initFiles, err := ensureAddRepositoryInitializedWithDetails(config.EngineOverride, config.Verbose)
139+
if err != nil {
140+
return err
141+
}
142+
138143
// Step 7: Determine files to add
139-
filesToAdd, initFiles, err := config.determineFilesToAdd()
144+
filesToAdd, _, err := config.determineFilesToAdd()
140145
if err != nil {
141146
return err
142147
}
@@ -311,6 +316,13 @@ func (c *AddInteractiveConfig) confirmChanges(workflowFiles, initFiles []string,
311316
addInteractiveLog.Print("Confirming changes with user")
312317

313318
fmt.Fprintln(os.Stderr, "")
319+
if len(initFiles) > 0 {
320+
fmt.Fprintln(os.Stderr, "The repository will also be initialized with:")
321+
for _, f := range initFiles {
322+
fmt.Fprintf(os.Stderr, " • %s\n", f)
323+
}
324+
fmt.Fprintln(os.Stderr, "")
325+
}
314326

315327
confirmed := true // Default to yes
316328
form := console.NewConfirmForm(

0 commit comments

Comments
 (0)