Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# ADR-53766: Infer GitHub App Permissions and Events from Resolved Package Workflows in add-wizard Bootstrap

**Date**: 2026-08-19
**Status**: Draft
**Deciders**: Unknown

---

### Context

`gh aw add-wizard` bootstraps a GitHub App for packages that declare a `github-app` action in their `aw.yml` manifest. Previously, the manifest author had to manually list the App's `permissions` and `events` under `github-app`, duplicating information already expressed in each workflow's frontmatter (`permissions:`, `on:`, and `safe-outputs:` handlers). Manifests that omitted these fields silently produced a GitHub App scoped to only `metadata: read` with no subscribed webhook events, so the installed App could not actually perform the actions the package's workflows required (for example, writing issues or reacting to pull requests).

### Decision

We will infer the minimal GitHub App `permissions` and `events` requirements directly from the workflows reachable from a package's `aw.yml`, and merge them with any values still explicitly declared in the manifest. Permission inference reuses the same canonical helpers already used by the `.md` interactive workflow builder (`workflow.ComputeGitHubAppManifestPermissions`, which layers safe-outputs-derived permissions via `ComputePermissionsForSafeOutputs`/`SafeOutputsConfigFromKeys` on top of the raw top-level `permissions` block, keeping the highest scope seen per resource) and normalizes Actions permission keys to GitHub App manifest keys, dropping scopes with no App equivalent. Event inference uses `workflow.NormalizeGitHubAppWebhookEvents` to expand compiler-only triggers (command shorthands, `slash_command`, `label_command`, `reaction`, `status-comment`) to their underlying webhook events, map `pull_request_target` to `pull_request`, and filter out non-webhook triggers (`schedule`, `workflow_dispatch`, `repository_dispatch`). Inference is scoped to only the workflows resolved from the specific package associated with the selected bootstrap profile (`config.Profile.Source`), not the full set of sources passed to add-wizard, so an unrelated standalone workflow installed alongside a package cannot widen that package's App scope. A `--no-config` flag on `gh aw add-wizard` disables this inference entirely, restoring the previous behavior of only applying explicitly declared `permissions`/`events`.

### Alternatives Considered

#### Alternative 1: Require manifest authors to keep declaring permissions/events explicitly

Leave the existing behavior unchanged and instead improve documentation or add a linter warning when `github-app` permissions/events are missing from `aw.yml`. Why not chosen: this still requires every package author to manually keep the manifest's App scopes in sync with the workflows' actual requirements, which is exactly the duplication and silent-drift problem the issue calls out; a warning does not prevent an under-scoped App from being created.

#### Alternative 2: Hand-roll a separate permission/event derivation pass in the CLI package

Implement bespoke frontmatter parsing inside `pkg/cli` to compute permissions and events for `aw.yml` inference, independent of the logic already used for `.md` workflow permission derivation in the interactive builder. Why not chosen: this would create two divergent code paths for deriving GitHub App requirements from workflow frontmatter — one for `.md` workflows and one for `aw.yml` packages — that would need to be kept in sync manually and would be prone to the same normalization bugs (Actions-style keys vs. App manifest keys, compiler-only trigger expansion) independently in each place.

### Consequences

#### Positive
- Package `aw.yml` manifests no longer need to duplicate `permissions`/`events` that are already derivable from their workflows; omitting them no longer silently produces an under-scoped App.
- Permission and event derivation for `aw.yml` packages and `.md` interactive workflow building now share one canonical implementation (`pkg/workflow/github_app_requirements.go`), reducing the risk of divergent or incorrect normalization logic.
- Inference is scoped per-package (via the bootstrap profile's own source), preserving least-privilege when multiple unrelated packages/workflows are installed together.

#### Negative
- Inference adds a workflow-resolution pass (parsing every workflow reachable from the package) to `executeBootstrapProfile`, which is extra work compared to reading a few manifest fields directly, though bounded by the size of the package.
- Authors who want an App scoped more narrowly than what its workflows technically use (e.g., deliberately under-provisioning) must now use `--no-config` and revert to fully manual manifest declarations, since inferred values are additive/merged rather than a ceiling.

#### Neutral
- `aw.yml` manifests may still declare `permissions`/`events` explicitly; declared values are merged with (not replaced by) inferred values, so manifests can supplement inference for cases the inference cannot see (e.g., App requirements unrelated to any workflow trigger).
- The `--no-config` flag preserves an escape hatch for the previous fully-manual behavior without removing any existing manifest fields or schema.

---

*ADR created by adr-writer. Review and finalize before changing status from Draft to Accepted.*
2 changes: 1 addition & 1 deletion docs/src/content/docs/setup/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,7 @@ gh aw add-wizard https://example.com/workflows/my-workflow.json # Arbitrary UR
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`
**Options:** `--no-secret`, `--dir/-d`, `--engine/-e`, `--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.

Expand Down
7 changes: 6 additions & 1 deletion pkg/cli/add_interactive_orchestrator.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@ type AddInteractiveConfig struct {
AppendText string // Extra content to append to the workflow on installation
DisableSecurityScanner bool // Disable security scanning of workflow markdown content

// DisableGitHubAppPermissionInference disables inferring GitHub App
// permissions/events from the package's resolved workflows during bootstrap,
// so only permissions/events explicitly declared in aw.yml are applied.
DisableGitHubAppPermissionInference bool

// UseCopilotRequests indicates the user chose org-billing (copilot-requests) auth
// instead of a PAT when setting up the Copilot engine during the wizard.
// When true, COPILOT_GITHUB_TOKEN secret setup is skipped and
Expand Down Expand Up @@ -166,7 +171,7 @@ func (c *AddInteractiveConfig) applyBootstrapConfigIfNeeded(ctx context.Context,
return nil
}
if c.hasWriteAccess {
return executeBootstrapConfigForAdd(ctx, c.RepoOverride, c.WorkflowSpecs, profile, c.UseCopilotRequests, c.Verbose)
return executeBootstrapConfigForAdd(ctx, c.RepoOverride, c.WorkflowSpecs, profile, c.UseCopilotRequests, c.Verbose, c.DisableGitHubAppPermissionInference)
}
printBootstrapConfigTODO(os.Stderr, profile)
return nil
Expand Down
27 changes: 17 additions & 10 deletions pkg/cli/add_wizard_command.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ Note: To create a new workflow from scratch, use the 'new' command instead.`,
` + string(constants.CLIExtensionPrefix) + ` add-wizard githubnext/agentics/ci-doctor --no-secret # Skip secret prompt
` + string(constants.CLIExtensionPrefix) + ` add-wizard githubnext/agentics/ci-doctor --append "custom footer" # Append custom content
` + string(constants.CLIExtensionPrefix) + ` add-wizard githubnext/agentics/ci-doctor --no-security-scanner # Skip security scan
` + string(constants.CLIExtensionPrefix) + ` add-wizard githubnext/agentics/ci-doctor --no-config # Skip GitHub App permission/event inference from package workflows
`,
Args: func(cmd *cobra.Command, args []string) error {
if len(args) < 1 {
Expand All @@ -74,6 +75,7 @@ Note: To create a new workflow from scratch, use the 'new' command instead.`,
skipSecret := noSecret || skipSecretLegacy
appendText, _ := cmd.Flags().GetString("append")
disableSecurityScanner := resolveDeprecatedBoolFlag(cmd, "no-security-scanner", "disable-security-scanner")
noGitHubAppInference, _ := cmd.Flags().GetBool("no-config")

addWizardLog.Printf("Starting add-wizard: workflows=%v, engine=%s, verbose=%v", workflows, engineOverride, verbose)

Expand All @@ -90,16 +92,17 @@ Note: To create a new workflow from scratch, use the 'new' command instead.`,
}

return RunAddInteractive(cmd.Context(), &AddInteractiveConfig{
WorkflowSpecs: workflows,
Verbose: verbose,
EngineOverride: engineOverride,
NoGitattributes: noGitattributes,
WorkflowDir: workflowDir,
NoStopAfter: noStopAfter,
StopAfter: stopAfter,
SkipSecret: skipSecret,
AppendText: appendText,
DisableSecurityScanner: disableSecurityScanner,
WorkflowSpecs: workflows,
Verbose: verbose,
EngineOverride: engineOverride,
NoGitattributes: noGitattributes,
WorkflowDir: workflowDir,
NoStopAfter: noStopAfter,
StopAfter: stopAfter,
SkipSecret: skipSecret,
AppendText: appendText,
DisableSecurityScanner: disableSecurityScanner,
DisableGitHubAppPermissionInference: noGitHubAppInference,
})
},
}
Expand Down Expand Up @@ -131,6 +134,10 @@ Note: To create a new workflow from scratch, use the 'new' command instead.`,
// for consistency with add and other install entry points)
addSecurityScannerFlag(cmd)

// Add no-config flag to allow disabling automatic inference of GitHub App
// permissions/events from resolved package workflows.
cmd.Flags().Bool("no-config", false, "Disable inferring GitHub App permissions/events from the package's workflows; use only permissions/events declared in aw.yml")

// Register completions
RegisterEngineFlagCompletion(cmd)
RegisterDirFlagCompletion(cmd, "dir")
Expand Down
11 changes: 11 additions & 0 deletions pkg/cli/add_wizard_command_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,4 +49,15 @@ func TestAddWizardCommand_ExamplesMentionNewFlags(t *testing.T) {

assert.Contains(t, cmd.Example, "--append \"custom footer\"", "add-wizard examples should show append usage")
assert.Contains(t, cmd.Example, "--no-security-scanner", "add-wizard examples should show no-security-scanner usage")
assert.Contains(t, cmd.Example, "--no-config", "add-wizard examples should show no-config usage")
}

func TestAddWizardCommand_HasNoConfigFlag(t *testing.T) {
t.Parallel()
cmd := NewAddWizardCommand(func(string) error { return nil })
require.NotNil(t, cmd)

flag := cmd.Flags().Lookup("no-config")
require.NotNil(t, flag, "add-wizard should define no-config")
assert.Equal(t, "false", flag.DefValue, "no-config should default to false")
}
15 changes: 8 additions & 7 deletions pkg/cli/bootstrap_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ func printBootstrapConfigTODO(w io.Writer, profile *resolvedBootstrapProfile) {

// executeBootstrapConfigForAdd runs the bootstrap config actions interactively.
// Used by add-wizard after the workflow PR has been created and merged.
func executeBootstrapConfigForAdd(ctx context.Context, repo string, sources []string, profile *resolvedBootstrapProfile, useCopilotRequests bool, verbose bool) error {
func executeBootstrapConfigForAdd(ctx context.Context, repo string, sources []string, profile *resolvedBootstrapProfile, useCopilotRequests bool, verbose bool, disableGitHubAppPermissionInference bool) error {
if profile == nil || profile.Profile == nil || len(profile.Profile.Config) == 0 {
return nil
}
Expand All @@ -88,11 +88,12 @@ func executeBootstrapConfigForAdd(ctx context.Context, repo string, sources []st
}

return executeBootstrapProfile(ctx, bootstrapProfileRunConfig{
Repo: repo,
RepoDir: repoDir,
Sources: sources,
Profile: profile,
UseCopilotRequests: useCopilotRequests,
Verbose: verbose,
Repo: repo,
RepoDir: repoDir,
Sources: sources,
Profile: profile,
UseCopilotRequests: useCopilotRequests,
Verbose: verbose,
DisableGitHubAppPermissionInference: disableGitHubAppPermissionInference,
})
}
154 changes: 154 additions & 0 deletions pkg/cli/bootstrap_profile_inference.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
package cli

import (
"context"
"maps"
"sort"

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

var bootstrapInferenceLog = logger.New("cli:bootstrap_profile_inference")

// inferBootstrapGitHubAppRequirements resolves every workflow reachable from sources
// (an aw.yml package) and merges their required GitHub App manifest permissions and
// webhook events into the minimal set required for a GitHub App to operate the
// package. This lets aw.yml manifests omit the github-app config[].permissions/events
// fields and still get a least-privilege App instead of the "metadata: read"
// fallback.
//
// Requirement derivation is delegated to pkg/workflow (ComputeGitHubAppManifestPermissions
// and NormalizeGitHubAppWebhookEvents), the same code path used to determine safe-outputs
// permissions and trigger normalization for standalone .md workflows, so aw.yml packages
// and directly-added .md workflows are held to identical permission/event rules.
func inferBootstrapGitHubAppRequirements(ctx context.Context, sources []string) (map[string]string, []string, error) {
if len(sources) == 0 {
return nil, nil, nil
}
resolved, err := ResolveWorkflows(ctx, sources, false)
if err != nil {
return nil, nil, err
}

permissions := map[string]string{}
eventSet := map[string]struct{}{}
for _, candidate := range resolved.Workflows {
if candidate == nil || candidate.IsActionWorkflow || candidate.IsPackageSkillFile || candidate.IsPackageAgentFile {
continue
}
frontmatter, err := parser.ExtractFrontmatterFromContent(string(candidate.Content))
if err != nil || frontmatter == nil {
continue
}
safeOutputs := bootstrapSafeOutputsConfigFromFrontmatter(frontmatter.Frontmatter)
for resource, level := range workflow.ComputeGitHubAppManifestPermissions(frontmatter.Frontmatter["permissions"], safeOutputs) {
permissions[resource] = mergeBootstrapPermissionLevel(permissions[resource], level)
}
for _, event := range bootstrapEventNamesFromOn(frontmatter.Frontmatter["on"]) {
eventSet[event] = struct{}{}
}
}

events := make([]string, 0, len(eventSet))
for event := range eventSet {
events = append(events, event)
}
sort.Strings(events)

bootstrapInferenceLog.Printf("Inferred GitHub App requirements: permissions=%d, events=%d", len(permissions), len(events))
if len(permissions) == 0 {
permissions = nil
}
return permissions, events, nil
}

// bootstrapSafeOutputsConfigFromFrontmatter builds a minimal *workflow.SafeOutputsConfig
// from a workflow's raw "safe-outputs" frontmatter map, using the handler key names
// present (e.g. "create-issue", "add-comment"). This reuses workflow.SafeOutputsConfigFromKeys,
// the same helper the interactive workflow builder uses to compute safe-outputs-derived
// permissions for newly generated .md workflows, so package workflows are scoped with the
// same rules (e.g. an "issues: read" workflow with "create-issue" configured still yields
// "issues: write").
func bootstrapSafeOutputsConfigFromFrontmatter(frontmatter map[string]any) *workflow.SafeOutputsConfig {
safeOutputsRaw, ok := frontmatter["safe-outputs"].(map[string]any)
if !ok || len(safeOutputsRaw) == 0 {
return nil
}
keys := make([]string, 0, len(safeOutputsRaw))
for key := range safeOutputsRaw {
keys = append(keys, key)
}
return workflow.SafeOutputsConfigFromKeys(keys)
}

// bootstrapPermissionLevelRank ranks permission levels so higher-privilege scopes are
// never downgraded when merging requirements across multiple workflows or between
// declared aw.yml values and inferred ones.
func bootstrapPermissionLevelRank(level string) int {
switch level {
case "write":
return 2
case "read":
return 1
default:
return 0
}
}

// mergeBootstrapPermissionLevel returns the higher of two permission levels
// (write > read > none), so a resource needed as "write" by one workflow is never
// downgraded by another workflow that only needs "read".
func mergeBootstrapPermissionLevel(existing, incoming string) string {
if existing == "" {
return incoming
}
if bootstrapPermissionLevelRank(incoming) > bootstrapPermissionLevelRank(existing) {
return incoming
}
return existing
}

// bootstrapEventNamesFromOn extracts the GitHub App webhook events required by a
// workflow's "on" frontmatter value. It delegates to workflow.NormalizeGitHubAppWebhookEvents,
// the same trigger-normalization code path used elsewhere in the compiler, so
// compiler-only keys (slash_command, label_command, reaction, status-comment),
// command-trigger shorthands (e.g. "on: /my-bot"), and pull_request_target are all
// handled consistently rather than being reimplemented here.
func bootstrapEventNamesFromOn(raw any) []string {
return workflow.NormalizeGitHubAppWebhookEvents(raw)
}

// mergeBootstrapGitHubAppRequirements combines explicitly declared manifest
// permissions/events (if any) with the inferred requirements from the package's
// resolved workflows, taking the union of events and the highest permission level
// per resource.
func mergeBootstrapGitHubAppRequirements(declaredPermissions map[string]string, declaredEvents []string, inferredPermissions map[string]string, inferredEvents []string) (map[string]string, []string) {
merged := make(map[string]string, len(declaredPermissions)+len(inferredPermissions))
maps.Copy(merged, declaredPermissions)
for resource, level := range inferredPermissions {
merged[resource] = mergeBootstrapPermissionLevel(merged[resource], level)
}
if len(merged) == 0 {
merged = nil
}

eventSet := make(map[string]struct{}, len(declaredEvents)+len(inferredEvents))
for _, event := range declaredEvents {
eventSet[event] = struct{}{}
}
for _, event := range inferredEvents {
eventSet[event] = struct{}{}
}
events := make([]string, 0, len(eventSet))
for event := range eventSet {
events = append(events, event)
}
sort.Strings(events)
if len(events) == 0 {
events = nil
}

return merged, events
}
Loading
Loading