Skip to content
Merged
50 changes: 50 additions & 0 deletions docs/adr/45758-extend-bootstrap-config-to-add-and-wizard.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# ADR-45758: Extend Bootstrap Config Execution to `add` and `add-wizard` Commands

**Date**: 2026-07-15
**Status**: Draft
**Deciders**: Unknown

---

### Context

The `gh aw` CLI has three workflow-installation commands: `bootstrap` (dedicated setup runner), `add` (non-interactive batch install), and `add-wizard` (interactive install wizard). Package manifests (`aw.yml`) can declare a `config` section listing post-installation steps (set a repository variable, configure a secret, install a GitHub App, etc.). Before this change, only the `bootstrap` command read and acted on those config steps. Users running `gh aw add` or `gh aw add-wizard` received no indication that post-installation setup was required, creating a silent gap: workflows were installed but the configuration steps needed for them to work were never surfaced or executed.

### Decision

We will surface and execute bootstrap config steps from all three install entry points. The `add` command will print a checklist of required manual steps after installation; the `add-wizard` command will execute the steps interactively via the existing `executeBootstrapProfile` runner. Shared helpers (`printBootstrapConfigTODO`, `executeBootstrapConfigForAdd`) are extracted into a new `bootstrap_config.go` file and called from both commands. Concurrently, the `aw.yml` manifest schema is simplified from the nested `bootstrap: { actions: [...] }` structure to a flat top-level `config: [...]` array with strict per-action-type `anyOf` validation, replacing the former `additionalProperties: true` permissiveness.

### Alternatives Considered

#### Alternative 1: Document `gh aw bootstrap` as a required follow-up step

Users would be told in README/docs to run `gh aw bootstrap --repo OWNER/REPO` after `gh aw add`. The existing machinery would remain siloed in the `bootstrap` command.

Not chosen because it relies on users reading external documentation; in practice many users will skip this step, leaving their workflows misconfigured. A nudge within the install flow (a TODO checklist or interactive prompt) is more reliable.

#### Alternative 2: Duplicate bootstrap execution logic into each command

Rather than extracting shared helpers, each command (add, add-wizard) would contain its own copy of the profile-running logic.

Not chosen because duplication creates divergence risk: future changes to bootstrap behavior (new action types, error handling) would need to be applied in three places. Extracting to `bootstrap_config.go` keeps the three commands converged on a single execution path.

### Consequences

#### Positive
- Users installing workflows via `gh aw add` immediately see a TODO checklist of required post-install steps; they no longer need to know about the `bootstrap` subcommand.
- Users running `gh aw add-wizard` get interactive config setup as part of the wizard flow, reducing manual steps after PR creation.
- The new strict `anyOf` schema per action type rejects unknown fields at schema-validation time rather than silently accepting malformed manifests.
- All three commands converge on `executeBootstrapProfile`, so future changes to bootstrap execution propagate automatically.

#### Negative
- **Breaking schema change**: any existing package manifests using the old `bootstrap: { actions: [...] }` structure must be updated to `config: [...]`. There is no migration shim or backward compatibility path.
- The `add` and `add-wizard` commands now carry a secondary responsibility (bootstrap config surfacing) beyond installing workflows, increasing cognitive load for maintainers of those code paths.
- When multiple packages are installed simultaneously and more than one declares a `config` section, bootstrap config is silently skipped for all with only a warning log — the user must install packages separately to apply their config.

#### Neutral
- `ResolveWorkflows` now returns a `BootstrapProfile` field on `ResolvedWorkflows`, widening the surface of the resolution result type.
- The `add_command.go` flow is split from a single `AddWorkflows` call into explicit `ResolveWorkflows` + `AddResolvedWorkflows` stages to allow access to the resolved bootstrap profile before the add result is returned.

---

*ADR created by [adr-writer agent]. Review and finalize before changing status from Draft to Accepted.*
13 changes: 11 additions & 2 deletions pkg/cli/add_command.go
Original file line number Diff line number Diff line change
Expand Up @@ -152,8 +152,17 @@ func runAddCommand(cmd *cobra.Command, args []string, validateEngine func(string
StopAfter: stopAfter,
DisableSecurityScanner: disableSecurityScanner,
}
_, err := AddWorkflows(cmd.Context(), args, opts)
return err
resolved, err := ResolveWorkflows(cmd.Context(), args, verbose)
if err != nil {
return err
}
if _, err := AddResolvedWorkflows(cmd.Context(), args, resolved, opts); err != nil {
return err
}
if resolved.BootstrapProfile != nil {
printBootstrapConfigTODO(cmd.ErrOrStderr(), resolved.BootstrapProfile)
}
return nil
}

func registerAddCommandFlags(cmd *cobra.Command) {
Expand Down
11 changes: 11 additions & 0 deletions pkg/cli/add_interactive_orchestrator.go
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,17 @@ func RunAddInteractive(ctx context.Context, config *AddInteractiveConfig) error
return err
}

// Step 9b: Apply bootstrap config steps interactively (if the package declares any)
if config.resolvedWorkflows != nil && config.resolvedWorkflows.BootstrapProfile != nil {
if config.hasWriteAccess {
if err := executeBootstrapConfigForAdd(ctx, config.RepoOverride, config.WorkflowSpecs, config.resolvedWorkflows.BootstrapProfile, config.UseCopilotRequests, config.Verbose); err != nil {
return err
}
} else {
printBootstrapConfigTODO(os.Stderr, config.resolvedWorkflows.BootstrapProfile)
}
}

// Step 10: Check status and offer to run
if err := config.checkStatusAndOfferRun(ctx); err != nil {
return err
Expand Down
6 changes: 3 additions & 3 deletions pkg/cli/add_package_manifest.go
Original file line number Diff line number Diff line change
Expand Up @@ -294,9 +294,9 @@ func parseRepositoryPackageManifest(manifestPath string, content []byte) (*repos
warnings = append(warnings, agentWarnings...)
}

if bootstrapValue, ok := root["bootstrap"]; ok {
warnings = append(warnings, "Using experimental feature: manifest.bootstrap")
bootstrap, err := extractManifestBootstrap(bootstrapValue, manifestPath)
if configValue, ok := root["config"]; ok {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/codebase-design] Silent breaking change: existing manifests using bootstrap: { actions: [...] } will silently produce no bootstrap config — no error, no warning, no migration hint.

💡 Suggested fix

Add a deprecation warning when the old key is present:

if _, ok := root["bootstrap"]; ok {
    warnings = append(warnings, "manifest.bootstrap is deprecated; rename to config: [...] at the root level")
}
if configValue, ok := root["config"]; ok {

Without this, any existing package author who hasn't seen the rename will get a silent no-op — their bootstrap steps simply won't run.

@copilot please address this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The old bootstrap: key is not silently ignored — the schema has additionalProperties: false with no bootstrap property, so it's rejected at schema validation time with an explicit error ("Unknown property: bootstrap"). The new rejects_old_bootstrap_key_with_schema_error test in add_package_manifest_test.go documents and verifies this behavior.

warnings = append(warnings, "Using experimental feature: config")
bootstrap, err := extractManifestConfig(configValue, manifestPath)
Comment on lines +297 to +299

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The public contract is now consistently top-level config:. The schema removes the bootstrap property and defines config as the top-level array — manifests using the old bootstrap: { config: [...] } shape are rejected at schema validation time with a clear error ("Unknown property: bootstrap. Valid fields are: agents, branding, config, ..."). This is verified by the new rejects_old_bootstrap_key_with_schema_error test in add_package_manifest_test.go.

if err != nil {
return nil, nil, err
}
Expand Down
211 changes: 193 additions & 18 deletions pkg/cli/add_package_manifest_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -448,18 +448,17 @@ files:
switch path {
case "aw.yml":
return []byte(`name: Repo Assist
bootstrap:
actions:
- type: require-owner-type
owner: repo
value: org
- type: repo-variable
name: CENTRAL_AGENTIC_OPS_MODE
prompt: Rollout mode
default: preview
enum: [preview, review, live]
- type: handoff
message: Run gh aw run readiness.
config:
- type: require-owner-type
owner: repo
value: org
- type: repo-variable
name: CENTRAL_AGENTIC_OPS_MODE
prompt: Rollout mode
default: preview
enum: [preview, review, live]
- type: handoff
message: Run gh aw run readiness.
`), nil
case "README.md":
return []byte("# Repo Assist\n"), nil
Expand All @@ -481,12 +480,35 @@ bootstrap:
pkg, err := resolveRepositoryPackage(t.Context(), &RepoSpec{RepoSlug: "owner/repo"}, "")
require.NoError(t, err)
require.NotNil(t, pkg.Bootstrap)
require.Len(t, pkg.Bootstrap.Actions, 3)
assert.Equal(t, "require-owner-type", pkg.Bootstrap.Actions[0].Type)
assert.Equal(t, "repo-variable", pkg.Bootstrap.Actions[1].Type)
assert.Equal(t, []string{"preview", "review", "live"}, pkg.Bootstrap.Actions[1].Enum)
assert.Equal(t, "handoff", pkg.Bootstrap.Actions[2].Type)
assert.Contains(t, pkg.Warnings, "Using experimental feature: manifest.bootstrap")
require.Len(t, pkg.Bootstrap.Config, 3)
assert.Equal(t, "require-owner-type", pkg.Bootstrap.Config[0].Type)
assert.Equal(t, "repo-variable", pkg.Bootstrap.Config[1].Type)
assert.Equal(t, []string{"preview", "review", "live"}, pkg.Bootstrap.Config[1].Enum)
assert.Equal(t, "handoff", pkg.Bootstrap.Config[2].Type)
assert.Contains(t, pkg.Warnings, "Using experimental feature: config")
})

t.Run("rejects old bootstrap key with schema error", func(t *testing.T) {
downloadPackageFileFromGitHubForHost = func(_ context.Context, owner, repo, path, ref, host string) ([]byte, error) {
switch path {
case "aw.yml":
return []byte(`name: Repo Assist
bootstrap:
config:
- type: repo-variable
name: MY_VAR
prompt: Enter a value
`), nil
case "README.md":
return []byte("# Repo Assist\n"), nil
default:
return nil, createRepositoryPackageNotFoundError(path)
}
}

_, err := resolveRepositoryPackage(t.Context(), &RepoSpec{RepoSlug: "owner/repo"}, "")
require.Error(t, err, "old bootstrap key must produce an error, not be silently ignored")
assert.Contains(t, err.Error(), "bootstrap")
})

t.Run("rejects unsupported branding icon", func(t *testing.T) {
Expand Down Expand Up @@ -2021,3 +2043,156 @@ func TestIsGhAwRepository(t *testing.T) {
})
}
}

// bootstrapTestHelpers sets up the common mock functions used by bootstrap profile
// propagation tests and registers their cleanup.
func bootstrapTestHelpers(t *testing.T) {
t.Helper()
originalFetchFn := fetchWorkflowFromSourceWithContextFn
originalDownload := downloadPackageFileFromGitHubForHost
originalList := listPackageWorkflowFilesForHost
originalDirFiles := listPackageDirFilesForHost
originalDirSubdirs := listPackageDirSubdirsForHost
originalDefaultBranch := getRepositoryPackageDefaultBranch
t.Cleanup(func() {
fetchWorkflowFromSourceWithContextFn = originalFetchFn
downloadPackageFileFromGitHubForHost = originalDownload
listPackageWorkflowFilesForHost = originalList
listPackageDirFilesForHost = originalDirFiles
listPackageDirSubdirsForHost = originalDirSubdirs
getRepositoryPackageDefaultBranch = originalDefaultBranch
})

getRepositoryPackageDefaultBranch = func(repoSlug, host string) (string, error) {
return "main", nil
}
listPackageDirFilesForHost = func(_ context.Context, owner, repo, ref, dirPath, host string) ([]string, error) {
return nil, createRepositoryPackageNotFoundError(dirPath)
}
listPackageDirSubdirsForHost = func(_ context.Context, owner, repo, ref, dirPath, host string) ([]string, error) {
return nil, createRepositoryPackageNotFoundError(dirPath)
}
listPackageWorkflowFilesForHost = func(_ context.Context, owner, repo, ref, workflowPath, host string) ([]string, error) {
t.Fatalf("unexpected scan of %s", workflowPath)
return nil, nil
}
fetchWorkflowFromSourceWithContextFn = func(_ context.Context, spec *WorkflowSpec, _ bool) (*FetchedWorkflow, error) {
return &FetchedWorkflow{
Content: []byte("---\nname: Test\non: push\n---\n"),
CommitSHA: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
IsLocal: false,
SourcePath: spec.WorkflowPath,
}, nil
}
}

func TestResolveWorkflows_BootstrapProfile_SinglePackage(t *testing.T) {
bootstrapTestHelpers(t)

downloadPackageFileFromGitHubForHost = func(_ context.Context, owner, repo, path, ref, host string) ([]byte, error) {
switch path {
case "aw.yml":
return []byte(`name: My Package
files:
- workflows/review.md
config:
- type: repo-variable
name: MY_VAR
prompt: Enter a value
`), nil
case "README.md":
return []byte("# My Package\n"), nil
}
return nil, createRepositoryPackageNotFoundError(path)
}

resolved, err := ResolveWorkflows(context.Background(), []string{"owner/repo"}, false)
require.NoError(t, err)
require.Len(t, resolved.Workflows, 1)

require.NotNil(t, resolved.BootstrapProfile, "BootstrapProfile should be populated from the package config")
assert.Equal(t, "owner/repo", resolved.BootstrapProfile.PackageID)
require.Len(t, resolved.BootstrapProfile.Profile.Config, 1)
assert.Equal(t, "repo-variable", resolved.BootstrapProfile.Profile.Config[0].Type)
assert.Equal(t, "MY_VAR", resolved.BootstrapProfile.Profile.Config[0].Name)
}

func TestResolveWorkflows_BootstrapProfile_MultiplePackagesWarnsAndSuppresses(t *testing.T) {
bootstrapTestHelpers(t)

// Two separate repository packages, each declaring a config section.
downloadPackageFileFromGitHubForHost = func(_ context.Context, owner, repo, path, ref, host string) ([]byte, error) {
var pkgName, varName string
switch repo {
case "pkg-a":
pkgName, varName = "Package A", "VAR_A"
case "pkg-b":
pkgName, varName = "Package B", "VAR_B"
default:
return nil, createRepositoryPackageNotFoundError(path)
}
switch path {
case "aw.yml":
return fmt.Appendf(nil, `name: %s
files:
- workflows/review.md
config:
- type: repo-variable
name: %s
prompt: Enter a value
`, pkgName, varName), nil
case "README.md":
return []byte("# " + pkgName + "\n"), nil
}
return nil, createRepositoryPackageNotFoundError(path)
}

resolved, err := ResolveWorkflows(context.Background(), []string{"owner/pkg-a", "owner/pkg-b"}, false)
require.NoError(t, err)

assert.Nil(t, resolved.BootstrapProfile, "BootstrapProfile should be nil when multiple packages declare config")

// Verify the multi-profile warning is present (other deprecation/experimental warnings may also be present)
found := false
for _, w := range resolved.Warnings {
if strings.Contains(w, "multiple bootstrap profiles found") {
assert.Contains(t, w, "owner/pkg-a")
assert.Contains(t, w, "owner/pkg-b")
found = true
break
}
}
assert.True(t, found, "expected a warning about multiple bootstrap profiles, got: %v", resolved.Warnings)
}

func TestPrintBootstrapConfigTODO(t *testing.T) {
t.Run("noop when profile is nil", func(t *testing.T) {
var buf strings.Builder
printBootstrapConfigTODO(&buf, nil)
assert.Empty(t, buf.String())
})

t.Run("prints checklist items to provided writer", func(t *testing.T) {
profile := &resolvedBootstrapProfile{
PackageID: "owner/repo",
Profile: &repositoryPackageBootstrap{
Config: []repositoryPackageBootstrapAction{
{Type: "require-owner-type", Value: "org"},
{Type: "repo-variable", Name: "MY_VAR", Prompt: "Enter a value"},
{Type: "repo-secret", Name: "MY_SECRET", Prompt: "Enter secret"},
{Type: "copilot-auth", Secret: "COPILOT_TOKEN"},
{Type: "handoff", Message: "Run the bootstrap wizard."},
},
},
}
var buf strings.Builder
printBootstrapConfigTODO(&buf, profile)
out := buf.String()
assert.Contains(t, out, "owner/repo")
assert.Contains(t, out, "☐ Verify repository owner type: org")
assert.Contains(t, out, "☐ Set repository variable: MY_VAR")
assert.Contains(t, out, "☐ Set repository secret: MY_SECRET")
assert.Contains(t, out, "☐ Set Copilot PAT secret: COPILOT_TOKEN")
assert.Contains(t, out, "Run the bootstrap wizard.")
})
}
Loading
Loading