From 43db260310efbdf18375bd6bed86834d1c8e96cb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 3 Mar 2026 00:54:30 +0000 Subject: [PATCH 01/10] Initial plan From c959cd23ddf57694e2e7605365c4ea05f7134d1c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 3 Mar 2026 01:21:41 +0000 Subject: [PATCH 02/10] Rename app: to github-app: with deprecation and codemod (#issue) Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/aw/github-agentic-workflows.md | 6 +- docs/src/content/docs/reference/auth.mdx | 6 +- .../docs/reference/cross-repository.md | 2 +- .../docs/reference/frontmatter-full.md | 321 +++++++++++++++++- .../reference/safe-outputs-specification.md | 4 +- pkg/cli/codemod_github_app.go | 183 ++++++++++ pkg/cli/codemod_github_app_test.go | 281 +++++++++++++++ pkg/cli/fix_codemods.go | 1 + pkg/parser/schemas/main_workflow_schema.json | 93 ++++- pkg/workflow/checkout_manager.go | 25 +- pkg/workflow/mcp_github_config.go | 5 +- pkg/workflow/safe_outputs_config.go | 7 +- pkg/workflow/tools_parser.go | 5 +- ...github-mcp-access-control-specification.md | 10 +- 14 files changed, 909 insertions(+), 40 deletions(-) create mode 100644 pkg/cli/codemod_github_app.go create mode 100644 pkg/cli/codemod_github_app_test.go diff --git a/.github/aw/github-agentic-workflows.md b/.github/aw/github-agentic-workflows.md index 674b2e99341..478d5405b95 100644 --- a/.github/aw/github-agentic-workflows.md +++ b/.github/aw/github-agentic-workflows.md @@ -377,7 +377,7 @@ The YAML frontmatter supports these fields: - `read-only:` - The GitHub MCP server always operates in read-only mode; this field is accepted but has no effect - `github-token:` - Custom GitHub token - `lockdown:` - Enable lockdown mode to limit content surfaced from public repositories to items authored by users with push access (boolean, default: false) - - `app:` - GitHub App configuration for token minting; when set, mints an installation access token at workflow start that overrides `github-token` + - `github-app:` - GitHub App configuration for token minting; when set, mints an installation access token at workflow start that overrides `github-token` - `app-id:` - GitHub App ID (required, e.g., `${{ vars.APP_ID }}`) - `private-key:` - GitHub App private key (required, e.g., `${{ secrets.APP_PRIVATE_KEY }}`) - `owner:` - Optional installation owner (defaults to current repository owner) @@ -1064,7 +1064,7 @@ The YAML frontmatter supports these fields: - `concurrency-group:` - Concurrency group for the safe-outputs job (string) - When set, the safe-outputs job uses this concurrency group with `cancel-in-progress: false` - Supports GitHub Actions expressions, e.g., `"safe-outputs-${{ github.repository }}"` - - `app:` - GitHub App credentials for minting installation access tokens (object) + - `github-app:` - GitHub App credentials for minting installation access tokens (object) - When configured, generates a token from the app and uses it for all safe output operations (alternative to `github-token`) - Fields: - `app-id:` - GitHub App ID (required, e.g., `${{ vars.APP_ID }}`) @@ -1074,7 +1074,7 @@ The YAML frontmatter supports these fields: - Example: ```yaml safe-outputs: - app: + github-app: app-id: ${{ vars.APP_ID }} private-key: ${{ secrets.APP_PRIVATE_KEY }} create-issue: diff --git a/docs/src/content/docs/reference/auth.mdx b/docs/src/content/docs/reference/auth.mdx index 5cfc19d3936..830226d5a29 100644 --- a/docs/src/content/docs/reference/auth.mdx +++ b/docs/src/content/docs/reference/auth.mdx @@ -166,7 +166,7 @@ tools: github: mode: remote toolsets: [repos, issues, pull_requests] - app: + github-app: app-id: ${{ vars.APP_ID }} private-key: ${{ secrets.APP_PRIVATE_KEY }} owner: "my-org" # Optional: defaults to current repo owner @@ -186,7 +186,7 @@ You can also use GitHub App tokens for safe outputs operations: ```yaml wrap safe-outputs: - app: + github-app: app-id: ${{ vars.APP_ID }} private-key: ${{ secrets.APP_PRIVATE_KEY }} owner: "my-org" # optional: installation owner @@ -194,7 +194,7 @@ safe-outputs: create-issue: ``` -When you configure `app:` for safe outputs, tokens are minted with permissions specific to the safe output operations being performed, rather than the broader job-level permissions. This provides enhanced security by ensuring that tokens have the minimum necessary permissions for their specific use case. +When you configure `github-app:` for safe outputs, tokens are minted with permissions specific to the safe output operations being performed, rather than the broader job-level permissions. This provides enhanced security by ensuring that tokens have the minimum necessary permissions for their specific use case. For both tool authentication and safe outputs, you can scope the GitHub App token to specific repositories for enhanced security. This limits the token's access to only the repositories it needs to interact with. diff --git a/docs/src/content/docs/reference/cross-repository.md b/docs/src/content/docs/reference/cross-repository.md index 11a02bf983a..00907eefaaf 100644 --- a/docs/src/content/docs/reference/cross-repository.md +++ b/docs/src/content/docs/reference/cross-repository.md @@ -34,7 +34,7 @@ Or use GitHub App authentication: ```yaml wrap checkout: fetch-depth: 0 - app: + github-app: app-id: ${{ vars.APP_ID }} private-key: ${{ secrets.APP_PRIVATE_KEY }} ``` diff --git a/docs/src/content/docs/reference/frontmatter-full.md b/docs/src/content/docs/reference/frontmatter-full.md index 1db70834443..72c54de1a6f 100644 --- a/docs/src/content/docs/reference/frontmatter-full.md +++ b/docs/src/content/docs/reference/frontmatter-full.md @@ -761,6 +761,16 @@ permissions: # (optional) pull-requests: "read" + # Permission level for repository projects (read/write/none). Controls access to + # manage repository-level GitHub Projects boards. + # (optional) + repository-projects: "read" + + # Permission level for organization projects (read/write/none). Controls access to + # manage organization-level GitHub Projects boards. + # (optional) + organization-projects: "read" + # Permission level for security events (read/write/none). Controls access to view # and manage code scanning alerts and security findings. # (optional) @@ -1292,6 +1302,13 @@ engine: # Option 2: Maximum number of chat iterations per run as a string value max-turns: "example-value" + # Maximum number of continuations for multi-run autopilot mode. Default is 1 + # (single run, no autopilot). Values greater than 1 enable --autopilot mode for + # the copilot engine with --max-autopilot-continues set to this value. Note: Only + # supported by the copilot engine. + # (optional) + max-continuations: 1 + # Agent job concurrency configuration. Defaults to single job per engine across # all workflows (group: 'gh-aw-{engine-id}'). Supports full GitHub Actions # concurrency syntax. @@ -1422,7 +1439,7 @@ tools: args: [] # Array of strings - # GitHub MCP server read-only mode (always enforced; false is not permitted) + # Enable read-only mode to restrict GitHub MCP server to read-only operations only # (optional) read-only: true @@ -1449,11 +1466,50 @@ tools: mounts: [] # Array of Mount specification in format 'host:container:mode' + # Guard policy: repository access configuration. Restricts which repositories the + # agent can access. Use 'all' to allow all repos or an array of 'owner/repo' + # strings. + # (optional) + # This field supports multiple formats (oneOf): + + # Option 1: Allow access to all repositories + repos: "all" + + # Option 2: Allow access to specific repositories + repos: [] + # Array items: Repository slug in the format 'owner/repo' + + # Guard policy: minimum required integrity level for repository access. Restricts + # the agent to users with at least the specified permission level. + # (optional) + min-integrity: "reader" + # GitHub App configuration for token minting. When configured, a GitHub App # installation access token is minted at workflow start and used instead of the # default token. This token overrides any custom github-token setting and provides # fine-grained permissions matching the agent job requirements. # (optional) + github-app: + # GitHub App ID (e.g., '${{ vars.APP_ID }}'). Required to mint a GitHub App token. + app-id: "example-value" + + # GitHub App private key (e.g., '${{ secrets.APP_PRIVATE_KEY }}'). Required to + # mint a GitHub App token. + private-key: "example-value" + + # Optional owner of the GitHub App installation (defaults to current repository + # owner if not specified) + # (optional) + owner: "example-value" + + # Optional list of repositories to grant access to (defaults to current repository + # if not specified) + # (optional) + repositories: [] + # Array of strings + + # Deprecated: Use github-app instead. GitHub App configuration for token minting. + # (optional) app: # GitHub App ID (e.g., '${{ vars.APP_ID }}'). Required to mint a GitHub App token. app-id: "example-value" @@ -1628,7 +1684,7 @@ tools: serena: null # Option 2: Short syntax: array of language identifiers to enable (e.g., ["go", - # "typescript"]) + # "typescript"]). Note: rust does not generate a runtime setup step. serena: [] # Array items: string @@ -1640,8 +1696,7 @@ tools: # (optional) version: null - # Serena execution mode ('docker' is the only supported mode, runs in - # container) + # Serena execution mode (only 'docker' is supported, runs in container) # (optional) mode: "docker" @@ -1718,7 +1773,8 @@ tools: version: null # Configuration for Rust language support in Serena code analysis. Enables - # Rust-specific parsing, linting, and security checks. + # Rust-specific parsing, linting, and security checks. Note: rust does not + # generate a runtime setup step in GitHub Actions. # (optional) # This field supports multiple formats (oneOf): @@ -1988,6 +2044,11 @@ safe-outputs: # (optional) footer: true + # GitHub token to use for this specific output type. Overrides global github-token + # if specified. + # (optional) + github-token: "${{ secrets.GITHUB_TOKEN }}" + # Option 2: Enable issue creation with default configuration create-issue: null @@ -2398,6 +2459,11 @@ safe-outputs: # Option 3: Set to false to explicitly disable expiration expires: true + # GitHub token to use for this specific output type. Overrides global github-token + # if specified. + # (optional) + github-token: "${{ secrets.GITHUB_TOKEN }}" + # Option 2: Enable discussion creation with default configuration create-discussion: null @@ -2701,12 +2767,36 @@ safe-outputs: # Array of strings # Controls whether the workflow requests discussions:write permission for - # add-comment. Default: true (includes discussions:write). Set to false if your - # GitHub App lacks Discussions permission to prevent 422 errors during token - # generation. + # add-comment and includes discussions in the event trigger condition. Default: + # true (includes discussions:write). Set to false if your GitHub App lacks + # Discussions permission to prevent 422 errors during token generation. # (optional) discussions: true + # Controls whether the workflow requests issues:write permission for add-comment + # and includes issues in the event trigger condition. Default: true (includes + # issues:write). Set to false to disable issue commenting. + # (optional) + issues: true + + # Controls whether the workflow requests pull-requests:write permission for + # add-comment and includes pull requests in the event trigger condition. Default: + # true (includes pull-requests:write). Set to false to disable pull request + # commenting. + # (optional) + pull-requests: true + + # Controls whether AI-generated footer is added to the comment. When false, the + # visible footer content is omitted but XML markers (workflow-id, metadata) are + # still included for searchability. Defaults to true. + # (optional) + footer: true + + # GitHub token to use for this specific output type. Overrides global github-token + # if specified. + # (optional) + github-token: "${{ secrets.GITHUB_TOKEN }}" + # Option 2: Enable issue comment creation with default configuration add-comment: null @@ -2836,6 +2926,14 @@ safe-outputs: # (optional) fallback-as-issue: true + # Token used to push an empty commit after PR creation to trigger CI events. Works + # around the GITHUB_TOKEN limitation where pushes don't trigger workflow runs. + # Defaults to the magic secret GH_AW_CI_TRIGGER_TOKEN if set in the repository. + # Use a secret expression (e.g. '${{ secrets.CI_TOKEN }}') for a custom token, or + # 'app' for GitHub App auth. + # (optional) + github-token-for-extra-empty-commit: "example-value" + # Option 2: Enable pull request creation with default configuration create-pull-request: null @@ -3040,6 +3138,19 @@ safe-outputs: # (optional) github-token: "${{ secrets.GITHUB_TOKEN }}" + # Target repository in format 'owner/repo' for cross-repository code scanning + # alert creation. Takes precedence over trial target repo settings. + # (optional) + target-repo: "example-value" + + # List of additional repositories in format 'owner/repo' that code scanning alerts + # can be created in. When specified, the agent can use a 'repo' field in the + # output to specify which repository to create the alert in. The target repository + # (current or target-repo) is always implicitly allowed. + # (optional) + allowed-repos: [] + # Array of strings + # Option 2: Enable code scanning alert creation with default configuration # (unlimited findings) create-code-scanning-alert: null @@ -3180,6 +3291,14 @@ safe-outputs: # (optional) github-token: "${{ secrets.GITHUB_TOKEN }}" + # List of additional repositories in format 'owner/repo' that labels can be + # removed from. When specified, the agent can use a 'repo' field in the output to + # specify which repository to remove labels from. The target repository (current + # or target-repo) is always implicitly allowed. + # (optional) + allowed-repos: [] + # Array of strings + # Enable AI agents to request reviews from users or teams on pull requests based # on code changes or expertise matching. # (optional) @@ -3409,6 +3528,12 @@ safe-outputs: # (optional) github-token: "${{ secrets.GITHUB_TOKEN }}" + # List of allowed repositories in format 'owner/repo' for cross-repository user + # assignment operations. Use with 'repo' field in tool calls. + # (optional) + allowed-repos: [] + # Array of strings + # Enable AI agents to unassign users from issues or pull requests. Useful for # reassigning work or removing users from issues. # (optional) @@ -3568,6 +3693,19 @@ safe-outputs: # (optional) title-prefix: "example-value" + # List of additional repositories in format 'owner/repo' that issues can be + # updated in. When specified, the agent can use a 'repo' field in the output to + # specify which repository to update the issue in. The target repository (current + # or target-repo) is always implicitly allowed. + # (optional) + allowed-repos: [] + # Array of strings + + # GitHub token to use for this specific output type. Overrides global github-token + # if specified. + # (optional) + github-token: "${{ secrets.GITHUB_TOKEN }}" + # Option 2: Enable issue updating with default configuration update-issue: null @@ -3692,6 +3830,27 @@ safe-outputs: # (optional) staged: true + # Token used to push an empty commit after pushing changes to trigger CI events. + # Works around the GITHUB_TOKEN limitation where pushes don't trigger workflow + # runs. Defaults to the magic secret GH_AW_CI_TRIGGER_TOKEN if set in the + # repository. Use a secret expression (e.g. '${{ secrets.CI_TOKEN }}') for a + # custom token, or 'app' for GitHub App auth. + # (optional) + github-token-for-extra-empty-commit: "example-value" + + # Target repository in format 'owner/repo' for cross-repository push to pull + # request branch. Takes precedence over trial target repo settings. + # (optional) + target-repo: "example-value" + + # List of additional repositories in format 'owner/repo' that push to pull request + # branch can target. When specified, the agent can use a 'repo' field in the + # output to specify which repository to push to. The target repository (current or + # target-repo) is always implicitly allowed. + # (optional) + allowed-repos: [] + # Array of strings + # Enable AI agents to minimize (hide) comments on issues or pull requests based on # relevance, spam detection, or moderation rules. # (optional) @@ -3732,6 +3891,57 @@ safe-outputs: # (optional) discussions: true + # Enable AI agents to set or clear the type of GitHub issues. Use an empty string + # to clear the current type. + # (optional) + # This field supports multiple formats (oneOf): + + # Option 1: Null configuration allows setting any issue type + set-issue-type: null + + # Option 2: Configuration for setting the type of GitHub issues from agentic + # workflow output + set-issue-type: + # Optional list of allowed issue type names (e.g. 'Bug', 'Feature'). If omitted, + # any type is allowed. Empty string is always allowed to clear the type. + # (optional) + allowed: [] + # Array of strings + + # Optional maximum number of set-issue-type operations (default: 5). Supports + # integer or GitHub Actions expression (e.g. '${{ inputs.max }}'). + # (optional) + # This field supports multiple formats (oneOf): + + # Option 1: integer + max: 1 + + # Option 2: GitHub Actions expression that resolves to an integer at runtime + max: "example-value" + + # Target for issue type: 'triggering' (default), '*' (any issue), or explicit + # issue number + # (optional) + target: "example-value" + + # Target repository in format 'owner/repo' for cross-repository issue type + # setting. Takes precedence over trial target repo settings. + # (optional) + target-repo: "example-value" + + # List of additional repositories in format 'owner/repo' where issue types can be + # set. When specified, the agent can use a 'repo' field in the output to specify + # which repository to target. The target repository (current or target-repo) is + # always implicitly allowed. + # (optional) + allowed-repos: [] + # Array of strings + + # GitHub token to use for this specific output type. Overrides global github-token + # if specified. + # (optional) + github-token: "${{ secrets.GITHUB_TOKEN }}" + # Dispatch workflow_dispatch events to other workflows. Used by orchestrators to # delegate work to worker workflows with controlled maximum dispatch count. # (optional) @@ -3981,6 +4191,30 @@ safe-outputs: # a token will be generated using the app credentials and used for all safe output # operations. # (optional) + github-app: + # GitHub App ID. Should reference a variable (e.g., ${{ vars.APP_ID }}). + app-id: "example-value" + + # GitHub App private key. Should reference a secret (e.g., ${{ + # secrets.APP_PRIVATE_KEY }}). + private-key: "example-value" + + # Optional: The owner of the GitHub App installation. If empty, defaults to the + # current repository owner. + # (optional) + owner: "example-value" + + # Optional: Comma or newline-separated list of repositories to grant access to. If + # owner is set and repositories is empty, access will be scoped to all + # repositories in the provided repository owner's installation. If owner and + # repositories are empty, access will be scoped to only the current repository. + # (optional) + repositories: [] + # Array of strings + + # Deprecated: Use github-app instead. GitHub App credentials for minting + # installation access tokens. + # (optional) app: # GitHub App ID. Should reference a variable (e.g., ${{ vars.APP_ID }}). app-id: "example-value" @@ -4041,6 +4275,11 @@ safe-outputs: # (optional) steps: [] + # Runner specification for the detection job. Overrides agent.runs-on for the + # detection job only. Defaults to agent.runs-on. + # (optional) + runs-on: "example-value" + # Custom safe-output jobs that can be executed based on agentic workflow output. # Job names containing dashes will be automatically normalized to underscores # (e.g., 'send-notification' becomes 'send_notification'). @@ -4126,6 +4365,24 @@ safe-outputs: # (optional) agent-failure-comment: "example-value" + # Custom message template for pull request creation link appended to the + # activation comment. Available placeholders: {item_number}, {item_url}. Default: + # 'Pull request created: [#{item_number}]({item_url})' + # (optional) + pull-request-created: "example-value" + + # Custom message template for issue creation link appended to the activation + # comment. Available placeholders: {item_number}, {item_url}. Default: 'Issue + # created: [#{item_number}]({item_url})' + # (optional) + issue-created: "example-value" + + # Custom message template for commit push link appended to the activation comment. + # Available placeholders: {commit_sha}, {short_sha}, {commit_url}. Default: + # 'Commit pushed: [`{short_sha}`]({commit_url})' + # (optional) + commit-pushed: "example-value" + # When enabled, workflow completion notifier creates a new comment instead of # editing the activation comment. Creates an append-only timeline of workflow # runs. Default: false @@ -4179,9 +4436,16 @@ safe-outputs: # (optional) footer: true - # When true, creates a parent '[aw] Failed runs' issue that tracks all - # workflow failures as sub-issues. Helps organize failure tracking but may be - # unnecessary in smaller repositories. Defaults to false. + # When set to false or "false", disables all activation and fallback comments + # entirely (run-started, run-success, run-failure, PR/issue creation links). + # Supports templatable boolean values including GitHub Actions expressions (e.g. + # ${{ inputs.activation-comments }}). Default: true + # (optional) + activation-comments: null + + # When true, creates a parent '[aw] Failed runs' issue that tracks all workflow + # failures as sub-issues. Helps organize failure tracking but may be unnecessary + # in smaller repositories. Defaults to false. # (optional) group-reports: true @@ -4197,6 +4461,23 @@ safe-outputs: # Option 2: GitHub Actions expression that resolves to an integer at runtime max-bot-mentions: "example-value" + # Override the id-token permission for the safe-outputs job. Use 'write' to + # force-enable the id-token: write permission (required for OIDC authentication + # with cloud providers). Use 'none' to suppress automatic detection and prevent + # adding id-token: write even when vault/OIDC actions are detected in steps. By + # default, the compiler auto-detects known OIDC/vault actions + # (aws-actions/configure-aws-credentials, azure/login, google-github-actions/auth, + # hashicorp/vault-action, cyberark/conjur-action) and adds id-token: write + # automatically. + # (optional) + id-token: "write" + + # Concurrency group for the safe-outputs job. When set, the safe-outputs job will + # use this concurrency group with cancel-in-progress: false. Supports GitHub + # Actions expressions. + # (optional) + concurrency-group: "example-value" + # Runner specification for all safe-outputs jobs (activation, create-issue, # add-comment, etc.). Single runner label (e.g., 'ubuntu-slim', 'ubuntu-latest', # 'windows-latest', 'self-hosted'). Defaults to 'ubuntu-slim'. See @@ -4204,6 +4485,12 @@ safe-outputs: # (optional) runs-on: "example-value" + # Custom steps to inject into all safe-output jobs. These steps run after checking + # out the repository and setting up the action, and before any safe-output code + # executes. + # (optional) + steps: [] + # Configuration for secret redaction behavior in workflow outputs and artifacts # (optional) secret-masking: @@ -4291,6 +4578,18 @@ safe-inputs: # (optional) runtimes: {} + +# Checkout configuration for the agent job. Controls how actions/checkout is +# invoked. Can be a single checkout configuration or an array for multiple +# checkouts. +# (optional) +# This field supports multiple formats (oneOf): + +# Option 1: Single checkout configuration for the default workspace + +# Option 2: Multiple checkout configurations +checkout: [] + # Array items: undefined --- ``` diff --git a/docs/src/content/docs/reference/safe-outputs-specification.md b/docs/src/content/docs/reference/safe-outputs-specification.md index fdfaeb727d4..115be30dfc6 100644 --- a/docs/src/content/docs/reference/safe-outputs-specification.md +++ b/docs/src/content/docs/reference/safe-outputs-specification.md @@ -1947,7 +1947,7 @@ The optional `discussions` boolean field controls whether `discussions:write` pe ```yaml safe-outputs: - app: + github-app: app-id: ${{ secrets.APP_ID }} private-key: ${{ secrets.APP_PRIVATE_KEY }} owner: 'myorg' @@ -2912,7 +2912,7 @@ The optional `discussions` boolean field controls whether `discussions:write` pe ```yaml safe-outputs: - app: + github-app: app-id: ${{ secrets.APP_ID }} private-key: ${{ secrets.APP_PRIVATE_KEY }} owner: 'myorg' diff --git a/pkg/cli/codemod_github_app.go b/pkg/cli/codemod_github_app.go new file mode 100644 index 00000000000..524a7382935 --- /dev/null +++ b/pkg/cli/codemod_github_app.go @@ -0,0 +1,183 @@ +package cli + +import ( + "strings" + + "github.com/github/gh-aw/pkg/logger" +) + +var githubAppCodemodLog = logger.New("cli:codemod_github_app") + +// getGitHubAppCodemod creates a codemod for renaming 'app:' to 'github-app:' in workflow frontmatter. +// The 'app:' field under tools.github, safe-outputs, and checkout is deprecated in favour of 'github-app:'. +func getGitHubAppCodemod() Codemod { + return Codemod{ + ID: "app-to-github-app", + Name: "Rename 'app' to 'github-app'", + Description: "Renames the deprecated 'app:' field to 'github-app:' in tools.github, safe-outputs, and checkout configurations.", + IntroducedIn: "0.15.0", + Apply: func(content string, frontmatter map[string]any) (string, bool, error) { + if !hasDeprecatedAppField(frontmatter) { + return content, false, nil + } + newContent, applied, err := applyFrontmatterLineTransform(content, renameAppToGitHubApp) + if applied { + githubAppCodemodLog.Print("Renamed 'app' to 'github-app'") + } + return newContent, applied, err + }, + } +} + +// hasDeprecatedAppField returns true if any of the target sections contain a deprecated 'app:' field. +func hasDeprecatedAppField(frontmatter map[string]any) bool { + // Check tools.github.app + if toolsAny, hasTools := frontmatter["tools"]; hasTools { + if toolsMap, ok := toolsAny.(map[string]any); ok { + if githubAny, hasGitHub := toolsMap["github"]; hasGitHub { + if githubMap, ok := githubAny.(map[string]any); ok { + if _, hasApp := githubMap["app"]; hasApp { + return true + } + } + } + } + } + + // Check safe-outputs.app + if soAny, hasSO := frontmatter["safe-outputs"]; hasSO { + if soMap, ok := soAny.(map[string]any); ok { + if _, hasApp := soMap["app"]; hasApp { + return true + } + } + } + + // Check checkout.app (single object or array of objects) + if checkoutAny, hasCheckout := frontmatter["checkout"]; hasCheckout { + if checkoutMap, ok := checkoutAny.(map[string]any); ok { + if _, hasApp := checkoutMap["app"]; hasApp { + return true + } + } + if checkoutArr, ok := checkoutAny.([]any); ok { + for _, item := range checkoutArr { + if itemMap, ok := item.(map[string]any); ok { + if _, hasApp := itemMap["app"]; hasApp { + return true + } + } + } + } + } + + return false +} + +// renameAppToGitHubApp renames 'app:' to 'github-app:' within tools.github, safe-outputs, and checkout blocks. +func renameAppToGitHubApp(lines []string) ([]string, bool) { + var result []string + modified := false + + // Block tracking + var inTools, inToolsGithub, inSafeOutputs, inCheckout bool + var toolsIndent, toolsGithubIndent, safeOutputsIndent, checkoutIndent string + + for i, line := range lines { + trimmed := strings.TrimSpace(line) + + // Skip empty lines without resetting state + if len(trimmed) == 0 { + result = append(result, line) + continue + } + + // Exit blocks when indentation signals we've left them + if !strings.HasPrefix(trimmed, "#") { + if inToolsGithub && hasExitedBlock(line, toolsGithubIndent) { + inToolsGithub = false + } + if inTools && hasExitedBlock(line, toolsIndent) { + inTools = false + inToolsGithub = false + } + if inSafeOutputs && hasExitedBlock(line, safeOutputsIndent) { + inSafeOutputs = false + } + if inCheckout && hasExitedBlock(line, checkoutIndent) { + inCheckout = false + } + } + + // Detect block entries at any indentation level + if strings.HasPrefix(trimmed, "tools:") { + inTools = true + inToolsGithub = false + toolsIndent = getIndentation(line) + result = append(result, line) + continue + } + + if inTools && strings.HasPrefix(trimmed, "github:") { + inToolsGithub = true + toolsGithubIndent = getIndentation(line) + result = append(result, line) + continue + } + + if strings.HasPrefix(trimmed, "safe-outputs:") { + inSafeOutputs = true + safeOutputsIndent = getIndentation(line) + result = append(result, line) + continue + } + + if strings.HasPrefix(trimmed, "checkout:") { + inCheckout = true + checkoutIndent = getIndentation(line) + result = append(result, line) + continue + } + + // Rename 'app:' to 'github-app:' when inside a target block at the direct child level + if strings.HasPrefix(trimmed, "app:") { + lineIndent := getIndentation(line) + shouldRename := false + + // Direct child of tools.github (inside github: block) + if inToolsGithub && isDirectChild(lineIndent, toolsGithubIndent) { + shouldRename = true + } + + // Direct child of safe-outputs + if inSafeOutputs && isDirectChild(lineIndent, safeOutputsIndent) { + shouldRename = true + } + + // Direct child of checkout (or a list item inside checkout) + if inCheckout && isDirectChild(lineIndent, checkoutIndent) { + shouldRename = true + } + + if shouldRename { + newLine, replaced := findAndReplaceInLine(line, "app", "github-app") + if replaced { + result = append(result, newLine) + modified = true + githubAppCodemodLog.Printf("Renamed 'app' to 'github-app' on line %d", i+1) + continue + } + } + } + + result = append(result, line) + } + + return result, modified +} + +// isDirectChild returns true if childIndent is exactly one indentation level deeper than parentIndent. +// It handles both spaces and tabs but assumes consistent indentation within a file. +func isDirectChild(childIndent, parentIndent string) bool { + return len(childIndent) > len(parentIndent) +} diff --git a/pkg/cli/codemod_github_app_test.go b/pkg/cli/codemod_github_app_test.go new file mode 100644 index 00000000000..a01ebe8bd1c --- /dev/null +++ b/pkg/cli/codemod_github_app_test.go @@ -0,0 +1,281 @@ +//go:build !integration + +package cli + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// hasDeprecatedAppFieldInContent returns true if any line in the content has 'app:' as its YAML key +// (i.e., trimmed content starts with "app:" – matches the field name, not app-id: or github-app:) +func hasDeprecatedAppFieldInContent(content string) bool { + for _, line := range strings.Split(content, "\n") { + trimmed := strings.TrimSpace(line) + if trimmed == "app:" || strings.HasPrefix(trimmed, "app: ") || strings.HasPrefix(trimmed, "app:\t") { + return true + } + } + return false +} + +func TestGitHubAppCodemod(t *testing.T) { + codemod := getGitHubAppCodemod() + + t.Run("renames app to github-app under tools.github", func(t *testing.T) { + content := `--- +engine: copilot +tools: + github: + mode: remote + app: + app-id: ${{ vars.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} +--- + +# Test Workflow +` + frontmatter := map[string]any{ + "engine": "copilot", + "tools": map[string]any{ + "github": map[string]any{ + "mode": "remote", + "app": map[string]any{ + "app-id": "${{ vars.APP_ID }}", + "private-key": "${{ secrets.APP_PRIVATE_KEY }}", + }, + }, + }, + } + + result, modified, err := codemod.Apply(content, frontmatter) + require.NoError(t, err, "Should not error when applying codemod") + assert.True(t, modified, "Should modify content") + assert.Contains(t, result, "github-app:", "Should contain github-app field") + assert.False(t, hasDeprecatedAppFieldInContent(result), "Should not contain old app field") + }) + + t.Run("renames app to github-app under safe-outputs", func(t *testing.T) { + content := `--- +engine: copilot +safe-outputs: + app: + app-id: ${{ vars.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + create-issue: +--- + +# Test Workflow +` + frontmatter := map[string]any{ + "engine": "copilot", + "safe-outputs": map[string]any{ + "app": map[string]any{ + "app-id": "${{ vars.APP_ID }}", + "private-key": "${{ secrets.APP_PRIVATE_KEY }}", + }, + "create-issue": nil, + }, + } + + result, modified, err := codemod.Apply(content, frontmatter) + require.NoError(t, err, "Should not error when applying codemod") + assert.True(t, modified, "Should modify content") + assert.Contains(t, result, "github-app:", "Should contain github-app field") + assert.False(t, hasDeprecatedAppFieldInContent(result), "Should not contain old app field") + }) + + t.Run("renames app to github-app under checkout", func(t *testing.T) { + content := `--- +engine: copilot +checkout: + app: + app-id: ${{ vars.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} +--- + +# Test Workflow +` + frontmatter := map[string]any{ + "engine": "copilot", + "checkout": map[string]any{ + "app": map[string]any{ + "app-id": "${{ vars.APP_ID }}", + "private-key": "${{ secrets.APP_PRIVATE_KEY }}", + }, + }, + } + + result, modified, err := codemod.Apply(content, frontmatter) + require.NoError(t, err, "Should not error when applying codemod") + assert.True(t, modified, "Should modify content") + assert.Contains(t, result, "github-app:", "Should contain github-app field") + assert.False(t, hasDeprecatedAppFieldInContent(result), "Should not contain old app field") + }) + + t.Run("does not modify workflows without app field", func(t *testing.T) { + content := `--- +engine: copilot +tools: + github: + mode: remote + github-token: ${{ secrets.MY_TOKEN }} +--- + +# Test Workflow +` + frontmatter := map[string]any{ + "engine": "copilot", + "tools": map[string]any{ + "github": map[string]any{ + "mode": "remote", + "github-token": "${{ secrets.MY_TOKEN }}", + }, + }, + } + + result, modified, err := codemod.Apply(content, frontmatter) + require.NoError(t, err, "Should not error") + assert.False(t, modified, "Should not modify content without app field") + assert.Equal(t, content, result, "Content should remain unchanged") + }) + + t.Run("does not modify app field outside target sections", func(t *testing.T) { + content := `--- +engine: copilot +tools: + github: + mode: remote +--- + +# Test Workflow +` + frontmatter := map[string]any{ + "engine": "copilot", + "tools": map[string]any{ + "github": map[string]any{ + "mode": "remote", + }, + }, + } + + result, modified, err := codemod.Apply(content, frontmatter) + require.NoError(t, err, "Should not error") + assert.False(t, modified, "Should not modify content when no app field in target sections") + assert.Equal(t, content, result, "Content should remain unchanged") + }) + + t.Run("renames app in all three sections", func(t *testing.T) { + content := `--- +engine: copilot +tools: + github: + mode: remote + app: + app-id: ${{ vars.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} +safe-outputs: + app: + app-id: ${{ vars.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + create-issue: +--- + +# Test Workflow +` + frontmatter := map[string]any{ + "engine": "copilot", + "tools": map[string]any{ + "github": map[string]any{ + "mode": "remote", + "app": map[string]any{ + "app-id": "${{ vars.APP_ID }}", + "private-key": "${{ secrets.APP_PRIVATE_KEY }}", + }, + }, + }, + "safe-outputs": map[string]any{ + "app": map[string]any{ + "app-id": "${{ vars.APP_ID }}", + "private-key": "${{ secrets.APP_PRIVATE_KEY }}", + }, + "create-issue": nil, + }, + } + + result, modified, err := codemod.Apply(content, frontmatter) + require.NoError(t, err, "Should not error when applying codemod") + assert.True(t, modified, "Should modify content") + // Both app fields should be renamed + assert.False(t, hasDeprecatedAppFieldInContent(result), "Should not contain any old app fields") + }) + + t.Run("does not rename already migrated github-app field", func(t *testing.T) { + content := `--- +engine: copilot +tools: + github: + mode: remote + github-app: + app-id: ${{ vars.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} +--- + +# Test Workflow +` + frontmatter := map[string]any{ + "engine": "copilot", + "tools": map[string]any{ + "github": map[string]any{ + "mode": "remote", + "github-app": map[string]any{ + "app-id": "${{ vars.APP_ID }}", + "private-key": "${{ secrets.APP_PRIVATE_KEY }}", + }, + }, + }, + } + + result, modified, err := codemod.Apply(content, frontmatter) + require.NoError(t, err, "Should not error") + assert.False(t, modified, "Should not modify content with github-app field (already migrated)") + assert.Equal(t, content, result, "Content should remain unchanged") + }) + + t.Run("preserves comments and formatting", func(t *testing.T) { + content := `--- +engine: copilot +tools: + github: + mode: remote + # GitHub App for token minting + app: # Use a GitHub App + app-id: ${{ vars.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} +--- + +# Test Workflow +` + frontmatter := map[string]any{ + "engine": "copilot", + "tools": map[string]any{ + "github": map[string]any{ + "mode": "remote", + "app": map[string]any{ + "app-id": "${{ vars.APP_ID }}", + "private-key": "${{ secrets.APP_PRIVATE_KEY }}", + }, + }, + }, + } + + result, modified, err := codemod.Apply(content, frontmatter) + require.NoError(t, err, "Should not error") + assert.True(t, modified, "Should modify content") + assert.Contains(t, result, "# GitHub App for token minting", "Should preserve comment") + assert.Contains(t, result, "github-app: # Use a GitHub App", "Should preserve inline comment") + }) +} diff --git a/pkg/cli/fix_codemods.go b/pkg/cli/fix_codemods.go index cd029f0e78f..ddabbbd18a5 100644 --- a/pkg/cli/fix_codemods.go +++ b/pkg/cli/fix_codemods.go @@ -43,5 +43,6 @@ func GetAllCodemods() []Codemod { getPlaywrightDomainsCodemod(), // Migrate tools.playwright.allowed_domains to network.allowed getExpiresIntegerToStringCodemod(), // Convert expires integer (days) to string with 'd' suffix getSerenaLocalModeCodemod(), // Replace tools.serena mode: local with mode: docker + getGitHubAppCodemod(), // Rename deprecated 'app' to 'github-app' } } diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 5b75e742568..95036900a46 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -2990,7 +2990,7 @@ "description": "Guard policy: minimum required integrity level for repository access. Restricts the agent to users with at least the specified permission level.", "enum": ["reader", "writer", "maintainer", "admin"] }, - "app": { + "github-app": { "type": "object", "description": "GitHub App configuration for token minting. When configured, a GitHub App installation access token is minted at workflow start and used instead of the default token. This token overrides any custom github-token setting and provides fine-grained permissions matching the agent job requirements.", "properties": { @@ -3027,6 +3027,33 @@ "repositories": ["repo1", "repo2"] } ] + }, + "app": { + "type": "object", + "description": "Deprecated: Use github-app instead. GitHub App configuration for token minting.", + "properties": { + "app-id": { + "type": "string", + "description": "GitHub App ID (e.g., '${{ vars.APP_ID }}'). Required to mint a GitHub App token." + }, + "private-key": { + "type": "string", + "description": "GitHub App private key (e.g., '${{ secrets.APP_PRIVATE_KEY }}'). Required to mint a GitHub App token." + }, + "owner": { + "type": "string", + "description": "Optional owner of the GitHub App installation (defaults to current repository owner if not specified)" + }, + "repositories": { + "type": "array", + "description": "Optional list of repositories to grant access to (defaults to current repository if not specified)", + "items": { + "type": "string" + } + } + }, + "required": ["app-id", "private-key"], + "additionalProperties": false } }, "additionalProperties": false, @@ -6689,7 +6716,7 @@ "description": "GitHub token to use for safe output jobs. Typically a secret reference like ${{ secrets.GITHUB_TOKEN }} or ${{ secrets.CUSTOM_PAT }}", "examples": ["${{ secrets.GITHUB_TOKEN }}", "${{ secrets.CUSTOM_PAT }}", "${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}"] }, - "app": { + "github-app": { "type": "object", "description": "GitHub App credentials for minting installation access tokens. When configured, a token will be generated using the app credentials and used for all safe output operations.", "properties": { @@ -6720,6 +6747,37 @@ "required": ["app-id", "private-key"], "additionalProperties": false }, + "app": { + "type": "object", + "description": "Deprecated: Use github-app instead. GitHub App credentials for minting installation access tokens.", + "properties": { + "app-id": { + "type": "string", + "description": "GitHub App ID. Should reference a variable (e.g., ${{ vars.APP_ID }}).", + "examples": ["${{ vars.APP_ID }}", "${{ secrets.APP_ID }}"] + }, + "private-key": { + "type": "string", + "description": "GitHub App private key. Should reference a secret (e.g., ${{ secrets.APP_PRIVATE_KEY }}).", + "examples": ["${{ secrets.APP_PRIVATE_KEY }}"] + }, + "owner": { + "type": "string", + "description": "Optional: The owner of the GitHub App installation. If empty, defaults to the current repository owner.", + "examples": ["my-organization", "${{ github.repository_owner }}"] + }, + "repositories": { + "type": "array", + "description": "Optional: Comma or newline-separated list of repositories to grant access to. If owner is set and repositories is empty, access will be scoped to all repositories in the provided repository owner's installation. If owner and repositories are empty, access will be scoped to only the current repository.", + "items": { + "type": "string" + }, + "examples": [["repo1", "repo2"], ["my-repo"]] + } + }, + "required": ["app-id", "private-key"], + "additionalProperties": false + }, "max-patch-size": { "type": "integer", "description": "Maximum allowed size for git patches in kilobytes (KB). Defaults to 1024 KB (1 MB). If patch exceeds this size, the job will fail.", @@ -8059,7 +8117,7 @@ "description": "GitHub token for authentication. Use ${{ secrets.MY_TOKEN }} to reference a secret. Mutually exclusive with app. Credentials are always removed after checkout (persist-credentials: false is enforced).", "examples": ["${{ secrets.MY_PAT }}", "${{ secrets.CROSS_REPO_PAT }}"] }, - "app": { + "github-app": { "type": "object", "description": "GitHub App authentication. Mints a short-lived installation access token via actions/create-github-app-token. Mutually exclusive with github-token.", "required": ["app-id", "private-key"], @@ -8088,6 +8146,35 @@ }, "additionalProperties": false }, + "app": { + "type": "object", + "description": "Deprecated: Use github-app instead. GitHub App authentication. Mints a short-lived installation access token via actions/create-github-app-token. Mutually exclusive with github-token.", + "required": ["app-id", "private-key"], + "properties": { + "app-id": { + "type": "string", + "description": "GitHub App ID. Use ${{ vars.APP_ID }} to reference a variable.", + "examples": ["${{ vars.APP_ID }}"] + }, + "private-key": { + "type": "string", + "description": "GitHub App private key. Use ${{ secrets.APP_PRIVATE_KEY }} to reference a secret.", + "examples": ["${{ secrets.APP_PRIVATE_KEY }}"] + }, + "owner": { + "type": "string", + "description": "Owner of the GitHub App installation. Defaults to the current repository owner.", + "examples": ["my-org"] + }, + "repositories": { + "type": "array", + "description": "Repositories to grant the token access to. Defaults to the current repository. Use [\"*\"] for org-wide access.", + "items": { "type": "string" }, + "examples": [["repo-a", "repo-b"], ["*"]] + } + }, + "additionalProperties": false + }, "current": { "type": "boolean", "description": "Marks this checkout as the logical current repository for the workflow. When set to true, the AI agent will treat this repository as its primary working target. Only one checkout may have current set to true. Useful for central-repo workflows targeting a different repository." diff --git a/pkg/workflow/checkout_manager.go b/pkg/workflow/checkout_manager.go index af248f32fc4..afeec52267f 100644 --- a/pkg/workflow/checkout_manager.go +++ b/pkg/workflow/checkout_manager.go @@ -723,14 +723,23 @@ func checkoutConfigFromMap(m map[string]any) (*CheckoutConfig, error) { } // Parse app configuration for GitHub App-based authentication - if v, ok := m["app"]; ok { - appMap, ok := v.(map[string]any) - if !ok { - return nil, errors.New("checkout.app must be an object") - } - cfg.App = parseAppConfig(appMap) - if cfg.App.AppID == "" || cfg.App.PrivateKey == "" { - return nil, errors.New("checkout.app requires both app-id and private-key") + // Support both "github-app" (preferred) and "app" (deprecated) + appKey := "" + if _, ok := m["github-app"]; ok { + appKey = "github-app" + } else if _, ok := m["app"]; ok { + appKey = "app" + } + if appKey != "" { + if v, ok := m[appKey]; ok { + appMap, ok := v.(map[string]any) + if !ok { + return nil, fmt.Errorf("checkout.%s must be an object", appKey) + } + cfg.App = parseAppConfig(appMap) + if cfg.App.AppID == "" || cfg.App.PrivateKey == "" { + return nil, fmt.Errorf("checkout.%s requires both app-id and private-key", appKey) + } } } diff --git a/pkg/workflow/mcp_github_config.go b/pkg/workflow/mcp_github_config.go index 9c8442d5c7f..f55b88300a0 100644 --- a/pkg/workflow/mcp_github_config.go +++ b/pkg/workflow/mcp_github_config.go @@ -83,8 +83,9 @@ func hasGitHubTool(parsedTools *Tools) bool { // hasGitHubApp checks if a GitHub App is configured in the (merged) GitHub tool configuration func hasGitHubApp(githubTool any) bool { if toolConfig, ok := githubTool.(map[string]any); ok { - _, exists := toolConfig["app"] - return exists + _, hasGitHubApp := toolConfig["github-app"] + _, hasApp := toolConfig["app"] + return hasGitHubApp || hasApp } return false } diff --git a/pkg/workflow/safe_outputs_config.go b/pkg/workflow/safe_outputs_config.go index 2d320f5e1be..a59742d5ddf 100644 --- a/pkg/workflow/safe_outputs_config.go +++ b/pkg/workflow/safe_outputs_config.go @@ -504,7 +504,12 @@ func (c *Compiler) extractSafeOutputsConfig(frontmatter map[string]any) *SafeOut } // Handle app configuration for GitHub App token minting - if app, exists := outputMap["app"]; exists { + // Support both "github-app" (preferred) and "app" (deprecated) + if app, exists := outputMap["github-app"]; exists { + if appMap, ok := app.(map[string]any); ok { + config.App = parseAppConfig(appMap) + } + } else if app, exists := outputMap["app"]; exists { if appMap, ok := app.(map[string]any); ok { config.App = parseAppConfig(appMap) } diff --git a/pkg/workflow/tools_parser.go b/pkg/workflow/tools_parser.go index 25741dbb769..67a7658eecc 100644 --- a/pkg/workflow/tools_parser.go +++ b/pkg/workflow/tools_parser.go @@ -230,7 +230,10 @@ func parseGitHubTool(val any) *GitHubToolConfig { } // Parse app configuration for GitHub App token minting - if app, ok := configMap["app"].(map[string]any); ok { + // Support both "github-app" (preferred) and "app" (deprecated) + if app, ok := configMap["github-app"].(map[string]any); ok { + config.App = parseAppConfig(app) + } else if app, ok := configMap["app"].(map[string]any); ok { config.App = parseAppConfig(app) } diff --git a/scratchpad/github-mcp-access-control-specification.md b/scratchpad/github-mcp-access-control-specification.md index a0699174b54..194388d6fd0 100644 --- a/scratchpad/github-mcp-access-control-specification.md +++ b/scratchpad/github-mcp-access-control-specification.md @@ -498,17 +498,17 @@ tools: **Security Best Practice**: Use `lockdown: true` for workflows that should only access the triggering repository, preventing unintended cross-repository operations. -#### 4.2.9 app (Existing Feature) +#### 4.2.9 github-app (Renamed from app) **Type**: Object (GitHubAppConfig) **Required**: No **Default**: Not specified (uses standard token authentication) -The `app` field enables GitHub App-based authentication, allowing the workflow to mint short-lived installation access tokens with fine-grained permissions. +The `github-app` field enables GitHub App-based authentication, allowing the workflow to mint short-lived installation access tokens with fine-grained permissions. (The previous name `app` is deprecated but still supported.) **Configuration Structure**: ```yaml -app: +github-app: app-id: "${{ vars.APP_ID }}" # GitHub App ID (required) private-key: "${{ secrets.APP_PRIVATE_KEY }}" # App private key (required) owner: "myorg" # Optional: Installation owner (defaults to current repo owner) @@ -534,7 +534,7 @@ app: tools: github: mode: "remote" - app: + github-app: app-id: "${{ vars.GITHUB_APP_ID }}" private-key: "${{ secrets.GITHUB_APP_PRIVATE_KEY }}" owner: "my-organization" @@ -1796,7 +1796,7 @@ Use GitHub App for fine-grained, short-lived token authentication: tools: github: mode: "remote" - app: + github-app: app-id: "${{ vars.GITHUB_APP_ID }}" private-key: "${{ secrets.GITHUB_APP_PRIVATE_KEY }}" owner: "myorg" From 38806e2f580963c57d6b0f0b75f62a389648e7fc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 3 Mar 2026 01:34:45 +0000 Subject: [PATCH 03/10] Merge main and fix TestGetAllCodemods tests to expect 26 codemods Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/cli/fix_codemods_test.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pkg/cli/fix_codemods_test.go b/pkg/cli/fix_codemods_test.go index 947941eb76e..8e8ec9797db 100644 --- a/pkg/cli/fix_codemods_test.go +++ b/pkg/cli/fix_codemods_test.go @@ -43,7 +43,7 @@ func TestGetAllCodemods_ReturnsAllCodemods(t *testing.T) { codemods := GetAllCodemods() // Verify we have the expected number of codemods - expectedCount := 25 + expectedCount := 26 assert.Len(t, codemods, expectedCount, "Should return all %d codemods", expectedCount) // Verify all codemods have required fields @@ -129,6 +129,7 @@ func TestGetAllCodemods_InExpectedOrder(t *testing.T) { "playwright-allowed-domains-migration", "expires-integer-to-string", "serena-local-to-docker", + "app-to-github-app", } require.Len(t, codemods, len(expectedOrder), "Should have expected number of codemods") From eb04c5038d2cdd160f91be11f37991bdf3648b11 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 3 Mar 2026 01:46:09 +0000 Subject: [PATCH 04/10] Apply reviewer comments: precedence fix, naming, schema description, checkout array test Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/cli/codemod_github_app.go | 21 +++++++------ pkg/cli/codemod_github_app_test.go | 32 ++++++++++++++++++++ pkg/parser/schemas/main_workflow_schema.json | 2 +- pkg/workflow/tools_parser.go | 11 +++++-- 4 files changed, 52 insertions(+), 14 deletions(-) diff --git a/pkg/cli/codemod_github_app.go b/pkg/cli/codemod_github_app.go index 524a7382935..05217577c14 100644 --- a/pkg/cli/codemod_github_app.go +++ b/pkg/cli/codemod_github_app.go @@ -139,23 +139,23 @@ func renameAppToGitHubApp(lines []string) ([]string, bool) { continue } - // Rename 'app:' to 'github-app:' when inside a target block at the direct child level + // Rename 'app:' to 'github-app:' when inside a target block if strings.HasPrefix(trimmed, "app:") { lineIndent := getIndentation(line) shouldRename := false - // Direct child of tools.github (inside github: block) - if inToolsGithub && isDirectChild(lineIndent, toolsGithubIndent) { + // Child of tools.github (inside github: block) + if inToolsGithub && isDescendant(lineIndent, toolsGithubIndent) { shouldRename = true } - // Direct child of safe-outputs - if inSafeOutputs && isDirectChild(lineIndent, safeOutputsIndent) { + // Child of safe-outputs + if inSafeOutputs && isDescendant(lineIndent, safeOutputsIndent) { shouldRename = true } - // Direct child of checkout (or a list item inside checkout) - if inCheckout && isDirectChild(lineIndent, checkoutIndent) { + // Child of checkout (or a list item inside checkout) + if inCheckout && isDescendant(lineIndent, checkoutIndent) { shouldRename = true } @@ -176,8 +176,9 @@ func renameAppToGitHubApp(lines []string) ([]string, bool) { return result, modified } -// isDirectChild returns true if childIndent is exactly one indentation level deeper than parentIndent. -// It handles both spaces and tabs but assumes consistent indentation within a file. -func isDirectChild(childIndent, parentIndent string) bool { +// isDescendant returns true if childIndent is deeper (more indented) than parentIndent. +// It is used as a "belongs to this block" check — any line more indented than the parent +// is treated as being within the parent's scope. +func isDescendant(childIndent, parentIndent string) bool { return len(childIndent) > len(parentIndent) } diff --git a/pkg/cli/codemod_github_app_test.go b/pkg/cli/codemod_github_app_test.go index a01ebe8bd1c..84d07f37eb9 100644 --- a/pkg/cli/codemod_github_app_test.go +++ b/pkg/cli/codemod_github_app_test.go @@ -245,6 +245,38 @@ tools: assert.Equal(t, content, result, "Content should remain unchanged") }) + t.Run("renames app to github-app inside checkout array item", func(t *testing.T) { + content := `--- +engine: copilot +checkout: + - repo: org/other-repo + app: + app-id: ${{ vars.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} +--- + +# Test Workflow +` + frontmatter := map[string]any{ + "engine": "copilot", + "checkout": []any{ + map[string]any{ + "repo": "org/other-repo", + "app": map[string]any{ + "app-id": "${{ vars.APP_ID }}", + "private-key": "${{ secrets.APP_PRIVATE_KEY }}", + }, + }, + }, + } + + result, modified, err := codemod.Apply(content, frontmatter) + require.NoError(t, err, "Should not error when applying codemod") + assert.True(t, modified, "Should modify content") + assert.Contains(t, result, "github-app:", "Should contain github-app field") + assert.False(t, hasDeprecatedAppFieldInContent(result), "Should not contain old app field") + }) + t.Run("preserves comments and formatting", func(t *testing.T) { content := `--- engine: copilot diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 95036900a46..78989d93a39 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -8114,7 +8114,7 @@ }, "github-token": { "type": "string", - "description": "GitHub token for authentication. Use ${{ secrets.MY_TOKEN }} to reference a secret. Mutually exclusive with app. Credentials are always removed after checkout (persist-credentials: false is enforced).", + "description": "GitHub token for authentication. Use ${{ secrets.MY_TOKEN }} to reference a secret. Mutually exclusive with github-app (and deprecated app). Credentials are always removed after checkout (persist-credentials: false is enforced).", "examples": ["${{ secrets.MY_PAT }}", "${{ secrets.CROSS_REPO_PAT }}"] }, "github-app": { diff --git a/pkg/workflow/tools_parser.go b/pkg/workflow/tools_parser.go index 67a7658eecc..bcf6c9cfce2 100644 --- a/pkg/workflow/tools_parser.go +++ b/pkg/workflow/tools_parser.go @@ -230,9 +230,14 @@ func parseGitHubTool(val any) *GitHubToolConfig { } // Parse app configuration for GitHub App token minting - // Support both "github-app" (preferred) and "app" (deprecated) - if app, ok := configMap["github-app"].(map[string]any); ok { - config.App = parseAppConfig(app) + // Support both "github-app" (preferred) and "app" (deprecated). + // "github-app" takes precedence whenever the key is present, regardless of its type. + if rawApp, exists := configMap["github-app"]; exists { + if appMap, ok := rawApp.(map[string]any); ok { + config.App = parseAppConfig(appMap) + } else { + toolsParserLog.Printf("Ignoring deprecated app configuration because github-app key is present with invalid type %T", rawApp) + } } else if app, ok := configMap["app"].(map[string]any); ok { config.App = parseAppConfig(app) } From 1db15119f65896bff01a241ca229e079ffa65310 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 3 Mar 2026 02:17:53 +0000 Subject: [PATCH 05/10] Completely remove deprecated app: field - only github-app: is supported Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/parser/schemas/main_workflow_schema.json | 87 -------------------- pkg/workflow/checkout_manager.go | 29 +++---- pkg/workflow/checkout_manager_test.go | 40 ++++----- pkg/workflow/mcp_github_config.go | 3 +- pkg/workflow/safe_outputs_app.go | 4 +- pkg/workflow/safe_outputs_config.go | 5 -- pkg/workflow/tools_parser.go | 6 -- pkg/workflow/tools_validation.go | 2 +- pkg/workflow/tools_validation_test.go | 10 +-- 9 files changed, 39 insertions(+), 147 deletions(-) diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 78989d93a39..85878a485b4 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -3027,33 +3027,6 @@ "repositories": ["repo1", "repo2"] } ] - }, - "app": { - "type": "object", - "description": "Deprecated: Use github-app instead. GitHub App configuration for token minting.", - "properties": { - "app-id": { - "type": "string", - "description": "GitHub App ID (e.g., '${{ vars.APP_ID }}'). Required to mint a GitHub App token." - }, - "private-key": { - "type": "string", - "description": "GitHub App private key (e.g., '${{ secrets.APP_PRIVATE_KEY }}'). Required to mint a GitHub App token." - }, - "owner": { - "type": "string", - "description": "Optional owner of the GitHub App installation (defaults to current repository owner if not specified)" - }, - "repositories": { - "type": "array", - "description": "Optional list of repositories to grant access to (defaults to current repository if not specified)", - "items": { - "type": "string" - } - } - }, - "required": ["app-id", "private-key"], - "additionalProperties": false } }, "additionalProperties": false, @@ -6747,37 +6720,6 @@ "required": ["app-id", "private-key"], "additionalProperties": false }, - "app": { - "type": "object", - "description": "Deprecated: Use github-app instead. GitHub App credentials for minting installation access tokens.", - "properties": { - "app-id": { - "type": "string", - "description": "GitHub App ID. Should reference a variable (e.g., ${{ vars.APP_ID }}).", - "examples": ["${{ vars.APP_ID }}", "${{ secrets.APP_ID }}"] - }, - "private-key": { - "type": "string", - "description": "GitHub App private key. Should reference a secret (e.g., ${{ secrets.APP_PRIVATE_KEY }}).", - "examples": ["${{ secrets.APP_PRIVATE_KEY }}"] - }, - "owner": { - "type": "string", - "description": "Optional: The owner of the GitHub App installation. If empty, defaults to the current repository owner.", - "examples": ["my-organization", "${{ github.repository_owner }}"] - }, - "repositories": { - "type": "array", - "description": "Optional: Comma or newline-separated list of repositories to grant access to. If owner is set and repositories is empty, access will be scoped to all repositories in the provided repository owner's installation. If owner and repositories are empty, access will be scoped to only the current repository.", - "items": { - "type": "string" - }, - "examples": [["repo1", "repo2"], ["my-repo"]] - } - }, - "required": ["app-id", "private-key"], - "additionalProperties": false - }, "max-patch-size": { "type": "integer", "description": "Maximum allowed size for git patches in kilobytes (KB). Defaults to 1024 KB (1 MB). If patch exceeds this size, the job will fail.", @@ -8146,35 +8088,6 @@ }, "additionalProperties": false }, - "app": { - "type": "object", - "description": "Deprecated: Use github-app instead. GitHub App authentication. Mints a short-lived installation access token via actions/create-github-app-token. Mutually exclusive with github-token.", - "required": ["app-id", "private-key"], - "properties": { - "app-id": { - "type": "string", - "description": "GitHub App ID. Use ${{ vars.APP_ID }} to reference a variable.", - "examples": ["${{ vars.APP_ID }}"] - }, - "private-key": { - "type": "string", - "description": "GitHub App private key. Use ${{ secrets.APP_PRIVATE_KEY }} to reference a secret.", - "examples": ["${{ secrets.APP_PRIVATE_KEY }}"] - }, - "owner": { - "type": "string", - "description": "Owner of the GitHub App installation. Defaults to the current repository owner.", - "examples": ["my-org"] - }, - "repositories": { - "type": "array", - "description": "Repositories to grant the token access to. Defaults to the current repository. Use [\"*\"] for org-wide access.", - "items": { "type": "string" }, - "examples": [["repo-a", "repo-b"], ["*"]] - } - }, - "additionalProperties": false - }, "current": { "type": "boolean", "description": "Marks this checkout as the logical current repository for the workflow. When set to true, the AI agent will treat this repository as its primary working target. Only one checkout may have current set to true. Useful for central-repo workflows targeting a different repository." diff --git a/pkg/workflow/checkout_manager.go b/pkg/workflow/checkout_manager.go index afeec52267f..bfb9b6fce53 100644 --- a/pkg/workflow/checkout_manager.go +++ b/pkg/workflow/checkout_manager.go @@ -723,29 +723,20 @@ func checkoutConfigFromMap(m map[string]any) (*CheckoutConfig, error) { } // Parse app configuration for GitHub App-based authentication - // Support both "github-app" (preferred) and "app" (deprecated) - appKey := "" - if _, ok := m["github-app"]; ok { - appKey = "github-app" - } else if _, ok := m["app"]; ok { - appKey = "app" - } - if appKey != "" { - if v, ok := m[appKey]; ok { - appMap, ok := v.(map[string]any) - if !ok { - return nil, fmt.Errorf("checkout.%s must be an object", appKey) - } - cfg.App = parseAppConfig(appMap) - if cfg.App.AppID == "" || cfg.App.PrivateKey == "" { - return nil, fmt.Errorf("checkout.%s requires both app-id and private-key", appKey) - } + if v, ok := m["github-app"]; ok { + appMap, ok := v.(map[string]any) + if !ok { + return nil, fmt.Errorf("checkout.github-app must be an object") + } + cfg.App = parseAppConfig(appMap) + if cfg.App.AppID == "" || cfg.App.PrivateKey == "" { + return nil, fmt.Errorf("checkout.github-app requires both app-id and private-key") } } - // Validate mutual exclusivity of github-token and app + // Validate mutual exclusivity of github-token and github-app if cfg.GitHubToken != "" && cfg.App != nil { - return nil, errors.New("checkout: github-token and app are mutually exclusive; use one or the other") + return nil, errors.New("checkout: github-token and github-app are mutually exclusive; use one or the other") } if v, ok := m["fetch-depth"]; ok { diff --git a/pkg/workflow/checkout_manager_test.go b/pkg/workflow/checkout_manager_test.go index 41b891255b5..325c725cd4d 100644 --- a/pkg/workflow/checkout_manager_test.go +++ b/pkg/workflow/checkout_manager_test.go @@ -267,26 +267,26 @@ func TestParseCheckoutConfigs(t *testing.T) { assert.Equal(t, "${{ secrets.MY_TOKEN }}", configs[0].GitHubToken, "legacy token should populate GitHubToken") }) - t.Run("app config is parsed", func(t *testing.T) { + t.Run("github-app config is parsed", func(t *testing.T) { raw := map[string]any{ "repository": "owner/target-repo", - "app": map[string]any{ + "github-app": map[string]any{ "app-id": "${{ vars.APP_ID }}", "private-key": "${{ secrets.APP_PRIVATE_KEY }}", }, } configs, err := ParseCheckoutConfigs(raw) - require.NoError(t, err, "app config should parse without error") + require.NoError(t, err, "github-app config should parse without error") require.Len(t, configs, 1) - require.NotNil(t, configs[0].App, "app config should be set") + require.NotNil(t, configs[0].App, "github-app config should be set") assert.Equal(t, "${{ vars.APP_ID }}", configs[0].App.AppID, "app-id should be set") assert.Equal(t, "${{ secrets.APP_PRIVATE_KEY }}", configs[0].App.PrivateKey, "private-key should be set") }) - t.Run("app config with owner and repositories", func(t *testing.T) { + t.Run("github-app config with owner and repositories", func(t *testing.T) { raw := map[string]any{ "repository": "owner/target-repo", - "app": map[string]any{ + "github-app": map[string]any{ "app-id": "${{ vars.APP_ID }}", "private-key": "${{ secrets.APP_PRIVATE_KEY }}", "owner": "my-org", @@ -294,55 +294,55 @@ func TestParseCheckoutConfigs(t *testing.T) { }, } configs, err := ParseCheckoutConfigs(raw) - require.NoError(t, err, "app config with owner should parse without error") + require.NoError(t, err, "github-app config with owner should parse without error") require.Len(t, configs, 1) require.NotNil(t, configs[0].App) assert.Equal(t, "my-org", configs[0].App.Owner) assert.Equal(t, []string{"repo-a", "repo-b"}, configs[0].App.Repositories) }) - t.Run("github-token and app are mutually exclusive", func(t *testing.T) { + t.Run("github-token and github-app are mutually exclusive", func(t *testing.T) { raw := map[string]any{ "github-token": "${{ secrets.MY_TOKEN }}", - "app": map[string]any{ + "github-app": map[string]any{ "app-id": "${{ vars.APP_ID }}", "private-key": "${{ secrets.APP_PRIVATE_KEY }}", }, } _, err := ParseCheckoutConfigs(raw) - require.Error(t, err, "github-token and app together should return error") + require.Error(t, err, "github-token and github-app together should return error") assert.Contains(t, err.Error(), "mutually exclusive", "error should mention mutual exclusivity") }) - t.Run("app config missing app-id returns error", func(t *testing.T) { + t.Run("github-app config missing app-id returns error", func(t *testing.T) { raw := map[string]any{ - "app": map[string]any{ + "github-app": map[string]any{ "private-key": "${{ secrets.APP_PRIVATE_KEY }}", }, } _, err := ParseCheckoutConfigs(raw) - require.Error(t, err, "app without app-id should return error") + require.Error(t, err, "github-app without app-id should return error") assert.Contains(t, err.Error(), "app-id and private-key") }) - t.Run("app config missing private-key returns error", func(t *testing.T) { + t.Run("github-app config missing private-key returns error", func(t *testing.T) { raw := map[string]any{ - "app": map[string]any{ + "github-app": map[string]any{ "app-id": "${{ vars.APP_ID }}", }, } _, err := ParseCheckoutConfigs(raw) - require.Error(t, err, "app without private-key should return error") + require.Error(t, err, "github-app without private-key should return error") assert.Contains(t, err.Error(), "app-id and private-key") }) - t.Run("app must be an object", func(t *testing.T) { + t.Run("github-app must be an object", func(t *testing.T) { raw := map[string]any{ - "app": "not-an-object", + "github-app": "not-an-object", } _, err := ParseCheckoutConfigs(raw) - require.Error(t, err, "non-object app should return error") - assert.Contains(t, err.Error(), "checkout.app must be an object") + require.Error(t, err, "non-object github-app should return error") + assert.Contains(t, err.Error(), "checkout.github-app must be an object") }) t.Run("array of objects", func(t *testing.T) { diff --git a/pkg/workflow/mcp_github_config.go b/pkg/workflow/mcp_github_config.go index f55b88300a0..d1f4ab40478 100644 --- a/pkg/workflow/mcp_github_config.go +++ b/pkg/workflow/mcp_github_config.go @@ -84,8 +84,7 @@ func hasGitHubTool(parsedTools *Tools) bool { func hasGitHubApp(githubTool any) bool { if toolConfig, ok := githubTool.(map[string]any); ok { _, hasGitHubApp := toolConfig["github-app"] - _, hasApp := toolConfig["app"] - return hasGitHubApp || hasApp + return hasGitHubApp } return false } diff --git a/pkg/workflow/safe_outputs_app.go b/pkg/workflow/safe_outputs_app.go index 13981e2aeaf..a0564d516b9 100644 --- a/pkg/workflow/safe_outputs_app.go +++ b/pkg/workflow/safe_outputs_app.go @@ -95,8 +95,8 @@ func (c *Compiler) mergeAppFromIncludedConfigs(topSafeOutputs *SafeOutputsConfig continue // Skip invalid JSON } - // Extract app from the safe-outputs.app field - if appData, exists := safeOutputsConfig["app"]; exists { + // Extract app from the safe-outputs.github-app field + if appData, exists := safeOutputsConfig["github-app"]; exists { if appMap, ok := appData.(map[string]any); ok { appConfig := parseAppConfig(appMap) diff --git a/pkg/workflow/safe_outputs_config.go b/pkg/workflow/safe_outputs_config.go index a59742d5ddf..c597df94be1 100644 --- a/pkg/workflow/safe_outputs_config.go +++ b/pkg/workflow/safe_outputs_config.go @@ -504,15 +504,10 @@ func (c *Compiler) extractSafeOutputsConfig(frontmatter map[string]any) *SafeOut } // Handle app configuration for GitHub App token minting - // Support both "github-app" (preferred) and "app" (deprecated) if app, exists := outputMap["github-app"]; exists { if appMap, ok := app.(map[string]any); ok { config.App = parseAppConfig(appMap) } - } else if app, exists := outputMap["app"]; exists { - if appMap, ok := app.(map[string]any); ok { - config.App = parseAppConfig(appMap) - } } } } diff --git a/pkg/workflow/tools_parser.go b/pkg/workflow/tools_parser.go index bcf6c9cfce2..525be40afd8 100644 --- a/pkg/workflow/tools_parser.go +++ b/pkg/workflow/tools_parser.go @@ -230,16 +230,10 @@ func parseGitHubTool(val any) *GitHubToolConfig { } // Parse app configuration for GitHub App token minting - // Support both "github-app" (preferred) and "app" (deprecated). - // "github-app" takes precedence whenever the key is present, regardless of its type. if rawApp, exists := configMap["github-app"]; exists { if appMap, ok := rawApp.(map[string]any); ok { config.App = parseAppConfig(appMap) - } else { - toolsParserLog.Printf("Ignoring deprecated app configuration because github-app key is present with invalid type %T", rawApp) } - } else if app, ok := configMap["app"].(map[string]any); ok { - config.App = parseAppConfig(app) } // Parse guard policy fields (flat syntax: repos and min-integrity directly under github:) diff --git a/pkg/workflow/tools_validation.go b/pkg/workflow/tools_validation.go index 32a8c400e76..16d02f93f42 100644 --- a/pkg/workflow/tools_validation.go +++ b/pkg/workflow/tools_validation.go @@ -96,7 +96,7 @@ func validateGitHubToolConfig(tools *Tools, workflowName string) error { if tools.GitHub.App != nil && tools.GitHub.GitHubToken != "" { toolsValidationLog.Printf("Invalid GitHub tool configuration in workflow: %s", workflowName) - return errors.New("invalid GitHub tool configuration: 'tools.github.app' and 'tools.github.github-token' cannot both be set. Use one authentication method: either 'app' (GitHub App) or 'github-token' (personal access token)") + return errors.New("invalid GitHub tool configuration: 'tools.github.github-app' and 'tools.github.github-token' cannot both be set. Use one authentication method: either 'github-app' (GitHub App) or 'github-token' (personal access token)") } return nil diff --git a/pkg/workflow/tools_validation_test.go b/pkg/workflow/tools_validation_test.go index ee34344ed1e..bc35f37d13f 100644 --- a/pkg/workflow/tools_validation_test.go +++ b/pkg/workflow/tools_validation_test.go @@ -294,10 +294,10 @@ func TestValidateGitHubToolConfig(t *testing.T) { shouldError: false, }, { - name: "github tool with app only is valid", + name: "github tool with github-app only is valid", toolsMap: map[string]any{ "github": map[string]any{ - "app": map[string]any{ + "github-app": map[string]any{ "app-id": "123456", "private-key": "${{ secrets.APP_PRIVATE_KEY }}", }, @@ -315,10 +315,10 @@ func TestValidateGitHubToolConfig(t *testing.T) { shouldError: false, }, { - name: "github tool with both app and github-token is invalid", + name: "github tool with both github-app and github-token is invalid", toolsMap: map[string]any{ "github": map[string]any{ - "app": map[string]any{ + "github-app": map[string]any{ "app-id": "123456", "private-key": "${{ secrets.APP_PRIVATE_KEY }}", }, @@ -326,7 +326,7 @@ func TestValidateGitHubToolConfig(t *testing.T) { }, }, shouldError: true, - errorMsg: "'tools.github.app' and 'tools.github.github-token' cannot both be set", + errorMsg: "'tools.github.github-app' and 'tools.github.github-token' cannot both be set", }, { name: "github tool with neither app nor github-token is valid", From 50f324cc82492323f03575e5324ad5ec83233b1a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 3 Mar 2026 02:41:57 +0000 Subject: [PATCH 06/10] Rename Go field App to GitHubApp in structs and update all references Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/workflow/checkout_manager.go | 38 +++++++++---------- pkg/workflow/checkout_manager_test.go | 20 +++++----- .../compiler_orchestrator_workflow.go | 4 +- pkg/workflow/compiler_safe_outputs_job.go | 6 +-- .../compiler_safe_outputs_job_test.go | 6 +-- pkg/workflow/compiler_safe_outputs_steps.go | 2 +- .../compiler_safe_outputs_steps_test.go | 2 +- pkg/workflow/compiler_types.go | 2 +- pkg/workflow/copilot_engine_execution.go | 2 +- pkg/workflow/github_mcp_app_token_test.go | 20 +++++----- pkg/workflow/mcp_github_config.go | 6 +-- pkg/workflow/notify_comment.go | 6 +-- pkg/workflow/safe_outputs_app.go | 4 +- pkg/workflow/safe_outputs_app_import_test.go | 22 +++++------ pkg/workflow/safe_outputs_app_test.go | 22 +++++------ pkg/workflow/safe_outputs_config.go | 2 +- pkg/workflow/safe_outputs_env.go | 6 +-- pkg/workflow/safe_outputs_jobs.go | 6 +-- pkg/workflow/tools_parser.go | 2 +- pkg/workflow/tools_types.go | 2 +- pkg/workflow/tools_validation.go | 2 +- 21 files changed, 91 insertions(+), 91 deletions(-) diff --git a/pkg/workflow/checkout_manager.go b/pkg/workflow/checkout_manager.go index bfb9b6fce53..240977d8d5c 100644 --- a/pkg/workflow/checkout_manager.go +++ b/pkg/workflow/checkout_manager.go @@ -49,15 +49,15 @@ type CheckoutConfig struct { // GitHubToken overrides the default GITHUB_TOKEN for authentication. // Use ${{ secrets.MY_TOKEN }} to reference a repository secret. // Maps to the "token" input of actions/checkout. - // Mutually exclusive with App. + // Mutually exclusive with GitHubApp. GitHubToken string `json:"github-token,omitempty"` - // App configures GitHub App-based authentication for this checkout. + // GitHubApp configures GitHub App-based authentication for this checkout. // When set, a token minting step is generated before checkout using // actions/create-github-app-token, and the minted token is passed // to actions/checkout as the "token" input. // Mutually exclusive with GitHubToken. - App *GitHubAppConfig `json:"app,omitempty"` + GitHubApp *GitHubAppConfig `json:"github-app,omitempty"` // FetchDepth controls the number of commits to fetch. // 0 fetches all history (full clone). 1 is a shallow clone (default). @@ -109,7 +109,7 @@ type resolvedCheckout struct { key checkoutKey ref string // last non-empty ref wins token string // last non-empty github-token wins - app *GitHubAppConfig // GitHub App config (first non-nil wins) + githubApp *GitHubAppConfig // GitHub App config (first non-nil wins) fetchDepth *int // nil means use default (1) sparsePatterns []string // merged sparse-checkout patterns submodules string @@ -173,8 +173,8 @@ func (cm *CheckoutManager) add(cfg *CheckoutConfig) { if cfg.GitHubToken != "" && entry.token == "" { entry.token = cfg.GitHubToken // first-seen github-token wins } - if cfg.App != nil && entry.app == nil { - entry.app = cfg.App // first-seen app wins + if cfg.GitHubApp != nil && entry.githubApp == nil { + entry.githubApp = cfg.GitHubApp // first-seen github-app wins } if cfg.SparseCheckout != "" { entry.sparsePatterns = mergeSparsePatterns(entry.sparsePatterns, cfg.SparseCheckout) @@ -197,7 +197,7 @@ func (cm *CheckoutManager) add(cfg *CheckoutConfig) { key: key, ref: cfg.Ref, token: cfg.GitHubToken, - app: cfg.App, + githubApp: cfg.GitHubApp, fetchDepth: cfg.FetchDepth, submodules: cfg.Submodules, lfs: cfg.LFS, @@ -240,7 +240,7 @@ func (cm *CheckoutManager) GetCurrentRepository() string { // HasAppAuth returns true if any checkout entry uses GitHub App authentication. func (cm *CheckoutManager) HasAppAuth() bool { for _, entry := range cm.ordered { - if entry.app != nil { + if entry.githubApp != nil { return true } } @@ -257,11 +257,11 @@ func (cm *CheckoutManager) HasAppAuth() bool { func (cm *CheckoutManager) GenerateCheckoutAppTokenSteps(c *Compiler, permissions *Permissions) []string { var steps []string for i, entry := range cm.ordered { - if entry.app == nil { + if entry.githubApp == nil { continue } checkoutManagerLog.Printf("Generating app token minting step for checkout index=%d repo=%q", i, entry.key.repository) - appSteps := c.buildGitHubAppTokenMintStep(entry.app, permissions) + appSteps := c.buildGitHubAppTokenMintStep(entry.githubApp, permissions) stepID := fmt.Sprintf("checkout-app-token-%d", i) for _, step := range appSteps { modified := strings.ReplaceAll(step, "id: safe-outputs-app-token", "id: "+stepID) @@ -276,7 +276,7 @@ func (cm *CheckoutManager) GenerateCheckoutAppTokenSteps(c *Compiler, permission func (cm *CheckoutManager) GenerateCheckoutAppTokenInvalidationSteps(c *Compiler) []string { var steps []string for i, entry := range cm.ordered { - if entry.app == nil { + if entry.githubApp == nil { continue } checkoutManagerLog.Printf("Generating app token invalidation step for checkout index=%d", i) @@ -353,9 +353,9 @@ func (cm *CheckoutManager) GenerateDefaultCheckoutStep( if override.ref != "" { fmt.Fprintf(&sb, " ref: %s\n", override.ref) } - // Determine effective token: app-minted token takes precedence + // Determine effective token: github-app-minted token takes precedence effectiveOverrideToken := override.token - if override.app != nil { + if override.githubApp != nil { // The default checkout is always at index 0 in the ordered list //nolint:gosec // G101: False positive - this is a GitHub Actions expression template placeholder, not a hardcoded credential effectiveOverrideToken = "${{ steps.checkout-app-token-0.outputs.token }}" @@ -421,9 +421,9 @@ func generateCheckoutStepLines(entry *resolvedCheckout, index int, getActionPin if entry.key.path != "" { fmt.Fprintf(&sb, " path: %s\n", entry.key.path) } - // Determine effective token: app-minted token takes precedence + // Determine effective token: github-app-minted token takes precedence effectiveToken := entry.token - if entry.app != nil { + if entry.githubApp != nil { //nolint:gosec // G101: False positive - this is a GitHub Actions expression template placeholder, not a hardcoded credential effectiveToken = fmt.Sprintf("${{ steps.checkout-app-token-%d.outputs.token }}", index) } @@ -582,7 +582,7 @@ func generateFetchStepLines(entry *resolvedCheckout, index int) string { // Determine authentication token token := entry.token - if entry.app != nil { + if entry.githubApp != nil { //nolint:gosec // G101: False positive - this is a GitHub Actions expression template placeholder, not a hardcoded credential token = fmt.Sprintf("${{ steps.checkout-app-token-%d.outputs.token }}", index) } @@ -728,14 +728,14 @@ func checkoutConfigFromMap(m map[string]any) (*CheckoutConfig, error) { if !ok { return nil, fmt.Errorf("checkout.github-app must be an object") } - cfg.App = parseAppConfig(appMap) - if cfg.App.AppID == "" || cfg.App.PrivateKey == "" { + cfg.GitHubApp = parseAppConfig(appMap) + if cfg.GitHubApp.AppID == "" || cfg.GitHubApp.PrivateKey == "" { return nil, fmt.Errorf("checkout.github-app requires both app-id and private-key") } } // Validate mutual exclusivity of github-token and github-app - if cfg.GitHubToken != "" && cfg.App != nil { + if cfg.GitHubToken != "" && cfg.GitHubApp != nil { return nil, errors.New("checkout: github-token and github-app are mutually exclusive; use one or the other") } diff --git a/pkg/workflow/checkout_manager_test.go b/pkg/workflow/checkout_manager_test.go index 325c725cd4d..f579e4cd79a 100644 --- a/pkg/workflow/checkout_manager_test.go +++ b/pkg/workflow/checkout_manager_test.go @@ -278,9 +278,9 @@ func TestParseCheckoutConfigs(t *testing.T) { configs, err := ParseCheckoutConfigs(raw) require.NoError(t, err, "github-app config should parse without error") require.Len(t, configs, 1) - require.NotNil(t, configs[0].App, "github-app config should be set") - assert.Equal(t, "${{ vars.APP_ID }}", configs[0].App.AppID, "app-id should be set") - assert.Equal(t, "${{ secrets.APP_PRIVATE_KEY }}", configs[0].App.PrivateKey, "private-key should be set") + require.NotNil(t, configs[0].GitHubApp, "github-app config should be set") + assert.Equal(t, "${{ vars.APP_ID }}", configs[0].GitHubApp.AppID, "app-id should be set") + assert.Equal(t, "${{ secrets.APP_PRIVATE_KEY }}", configs[0].GitHubApp.PrivateKey, "private-key should be set") }) t.Run("github-app config with owner and repositories", func(t *testing.T) { @@ -296,9 +296,9 @@ func TestParseCheckoutConfigs(t *testing.T) { configs, err := ParseCheckoutConfigs(raw) require.NoError(t, err, "github-app config with owner should parse without error") require.Len(t, configs, 1) - require.NotNil(t, configs[0].App) - assert.Equal(t, "my-org", configs[0].App.Owner) - assert.Equal(t, []string{"repo-a", "repo-b"}, configs[0].App.Repositories) + require.NotNil(t, configs[0].GitHubApp) + assert.Equal(t, "my-org", configs[0].GitHubApp.Owner) + assert.Equal(t, []string{"repo-a", "repo-b"}, configs[0].GitHubApp.Repositories) }) t.Run("github-token and github-app are mutually exclusive", func(t *testing.T) { @@ -927,7 +927,7 @@ func TestHasAppAuth(t *testing.T) { t.Run("returns true when default checkout has app", func(t *testing.T) { cm := NewCheckoutManager([]*CheckoutConfig{ - {App: &GitHubAppConfig{AppID: "${{ vars.APP_ID }}", PrivateKey: "${{ secrets.KEY }}"}}, + {GitHubApp: &GitHubAppConfig{AppID: "${{ vars.APP_ID }}", PrivateKey: "${{ secrets.KEY }}"}}, }) assert.True(t, cm.HasAppAuth(), "should be true when default checkout has app") }) @@ -935,7 +935,7 @@ func TestHasAppAuth(t *testing.T) { t.Run("returns true when additional checkout has app", func(t *testing.T) { cm := NewCheckoutManager([]*CheckoutConfig{ {GitHubToken: "${{ secrets.MY_PAT }}"}, - {Repository: "other/repo", Path: "deps", App: &GitHubAppConfig{AppID: "${{ vars.APP_ID }}", PrivateKey: "${{ secrets.KEY }}"}}, + {Repository: "other/repo", Path: "deps", GitHubApp: &GitHubAppConfig{AppID: "${{ vars.APP_ID }}", PrivateKey: "${{ secrets.KEY }}"}}, }) assert.True(t, cm.HasAppAuth(), "should be true when any checkout has app") }) @@ -946,7 +946,7 @@ func TestDefaultCheckoutWithAppAuth(t *testing.T) { t.Run("checkout step uses app token reference", func(t *testing.T) { cm := NewCheckoutManager([]*CheckoutConfig{ - {App: &GitHubAppConfig{AppID: "${{ vars.APP_ID }}", PrivateKey: "${{ secrets.KEY }}"}}, + {GitHubApp: &GitHubAppConfig{AppID: "${{ vars.APP_ID }}", PrivateKey: "${{ secrets.KEY }}"}}, }) lines := cm.GenerateDefaultCheckoutStep(false, "", getPin) combined := strings.Join(lines, "") @@ -963,7 +963,7 @@ func TestAdditionalCheckoutWithAppAuth(t *testing.T) { { Repository: "other/repo", Path: "deps", - App: &GitHubAppConfig{AppID: "${{ vars.APP_ID }}", PrivateKey: "${{ secrets.KEY }}"}, + GitHubApp: &GitHubAppConfig{AppID: "${{ vars.APP_ID }}", PrivateKey: "${{ secrets.KEY }}"}, }, }) lines := cm.GenerateAdditionalCheckoutSteps(getPin) diff --git a/pkg/workflow/compiler_orchestrator_workflow.go b/pkg/workflow/compiler_orchestrator_workflow.go index b5949e859ae..f2c6eb922e6 100644 --- a/pkg/workflow/compiler_orchestrator_workflow.go +++ b/pkg/workflow/compiler_orchestrator_workflow.go @@ -543,8 +543,8 @@ func (c *Compiler) extractAdditionalConfigurations( } // Populate the App field if it's not set in the top-level workflow but is in an included config - if workflowData.SafeOutputs != nil && workflowData.SafeOutputs.App == nil && includedApp != nil { - workflowData.SafeOutputs.App = includedApp + if workflowData.SafeOutputs != nil && workflowData.SafeOutputs.GitHubApp == nil && includedApp != nil { + workflowData.SafeOutputs.GitHubApp = includedApp } // Merge safe-outputs types from imports diff --git a/pkg/workflow/compiler_safe_outputs_job.go b/pkg/workflow/compiler_safe_outputs_job.go index 2d9d690d585..0a8b6ba1b77 100644 --- a/pkg/workflow/compiler_safe_outputs_job.go +++ b/pkg/workflow/compiler_safe_outputs_job.go @@ -243,8 +243,8 @@ func (c *Compiler) buildConsolidatedSafeOutputsJob(data *WorkflowData, mainJobNa } // Add GitHub App token minting step at the beginning if app is configured - if data.SafeOutputs.App != nil { - appTokenSteps := c.buildGitHubAppTokenMintStep(data.SafeOutputs.App, permissions) + if data.SafeOutputs.GitHubApp != nil { + appTokenSteps := c.buildGitHubAppTokenMintStep(data.SafeOutputs.GitHubApp, permissions) // Calculate insertion index: after setup action (if present) and artifact downloads, but before checkout and safe output steps insertIndex := 0 @@ -284,7 +284,7 @@ func (c *Compiler) buildConsolidatedSafeOutputsJob(data *WorkflowData, mainJobNa } // Add GitHub App token invalidation step at the end if app is configured - if data.SafeOutputs.App != nil { + if data.SafeOutputs.GitHubApp != nil { steps = append(steps, c.buildGitHubAppTokenInvalidationStep()...) } diff --git a/pkg/workflow/compiler_safe_outputs_job_test.go b/pkg/workflow/compiler_safe_outputs_job_test.go index 4fcba64abdc..b20d9d6911a 100644 --- a/pkg/workflow/compiler_safe_outputs_job_test.go +++ b/pkg/workflow/compiler_safe_outputs_job_test.go @@ -100,7 +100,7 @@ func TestBuildConsolidatedSafeOutputsJob(t *testing.T) { { name: "with GitHub App token", safeOutputs: &SafeOutputsConfig{ - App: &GitHubAppConfig{ + GitHubApp: &GitHubAppConfig{ AppID: "12345", PrivateKey: "test-key", }, @@ -403,7 +403,7 @@ func TestJobWithGitHubApp(t *testing.T) { workflowData := &WorkflowData{ Name: "Test Workflow", SafeOutputs: &SafeOutputsConfig{ - App: &GitHubAppConfig{ + GitHubApp: &GitHubAppConfig{ AppID: "12345", PrivateKey: "test-key", }, @@ -531,7 +531,7 @@ func TestGitHubAppWithPushToPRBranch(t *testing.T) { workflowData := &WorkflowData{ Name: "Test Workflow", SafeOutputs: &SafeOutputsConfig{ - App: &GitHubAppConfig{ + GitHubApp: &GitHubAppConfig{ AppID: "${{ vars.ACTIONS_APP_ID }}", PrivateKey: "${{ secrets.ACTIONS_PRIVATE_KEY }}", }, diff --git a/pkg/workflow/compiler_safe_outputs_steps.go b/pkg/workflow/compiler_safe_outputs_steps.go index 28df25aa4f3..e9617a3e359 100644 --- a/pkg/workflow/compiler_safe_outputs_steps.go +++ b/pkg/workflow/compiler_safe_outputs_steps.go @@ -129,7 +129,7 @@ func (c *Compiler) buildSharedPRCheckoutSteps(data *WorkflowData) []string { // Determine which token to use for checkout var checkoutToken string var gitRemoteToken string - if data.SafeOutputs.App != nil { + if data.SafeOutputs.GitHubApp != nil { //nolint:gosec // G101: False positive - this is a GitHub Actions expression template placeholder, not a hardcoded credential checkoutToken = "${{ steps.safe-outputs-app-token.outputs.token }}" //nolint:gosec //nolint:gosec // G101: False positive - this is a GitHub Actions expression template placeholder, not a hardcoded credential diff --git a/pkg/workflow/compiler_safe_outputs_steps_test.go b/pkg/workflow/compiler_safe_outputs_steps_test.go index 5142f7d0c4b..6aad571ec81 100644 --- a/pkg/workflow/compiler_safe_outputs_steps_test.go +++ b/pkg/workflow/compiler_safe_outputs_steps_test.go @@ -187,7 +187,7 @@ func TestBuildSharedPRCheckoutSteps(t *testing.T) { { name: "with GitHub App token", safeOutputs: &SafeOutputsConfig{ - App: &GitHubAppConfig{ + GitHubApp: &GitHubAppConfig{ AppID: "12345", PrivateKey: "test-key", }, diff --git a/pkg/workflow/compiler_types.go b/pkg/workflow/compiler_types.go index a5e470b75c3..9ee0dc7c329 100644 --- a/pkg/workflow/compiler_types.go +++ b/pkg/workflow/compiler_types.go @@ -479,7 +479,7 @@ type SafeOutputsConfig struct { NoOp *NoOpConfig `yaml:"noop,omitempty"` // No-op output for logging only (always available as fallback) ThreatDetection *ThreatDetectionConfig `yaml:"threat-detection,omitempty"` // Threat detection configuration Jobs map[string]*SafeJobConfig `yaml:"jobs,omitempty"` // Safe-jobs configuration (moved from top-level) - App *GitHubAppConfig `yaml:"app,omitempty"` // GitHub App credentials for token minting + GitHubApp *GitHubAppConfig `yaml:"github-app,omitempty"` // GitHub App credentials for token minting AllowedDomains []string `yaml:"allowed-domains,omitempty"` AllowGitHubReferences []string `yaml:"allowed-github-references,omitempty"` // Allowed repositories for GitHub references (e.g., ["repo", "org/repo2"]) Staged bool `yaml:"staged,omitempty"` // If true, emit step summary messages instead of making GitHub API calls diff --git a/pkg/workflow/copilot_engine_execution.go b/pkg/workflow/copilot_engine_execution.go index a3cbc540dae..d9c33c59c84 100644 --- a/pkg/workflow/copilot_engine_execution.go +++ b/pkg/workflow/copilot_engine_execution.go @@ -273,7 +273,7 @@ COPILOT_CLI_INSTRUCTION="$(cat /tmp/gh-aw/aw-prompts/prompt.txt)" if hasGitHubTool(workflowData.ParsedTools) { // If GitHub App is configured, use the app token (overrides custom and default tokens) - if workflowData.ParsedTools != nil && workflowData.ParsedTools.GitHub != nil && workflowData.ParsedTools.GitHub.App != nil { + if workflowData.ParsedTools != nil && workflowData.ParsedTools.GitHub != nil && workflowData.ParsedTools.GitHub.GitHubApp != nil { env["GITHUB_MCP_SERVER_TOKEN"] = "${{ steps.github-mcp-app-token.outputs.token }}" } else { customGitHubToken := getGitHubToken(workflowData.Tools["github"]) diff --git a/pkg/workflow/github_mcp_app_token_test.go b/pkg/workflow/github_mcp_app_token_test.go index 191dbe8fd8b..1aff591c468 100644 --- a/pkg/workflow/github_mcp_app_token_test.go +++ b/pkg/workflow/github_mcp_app_token_test.go @@ -25,7 +25,7 @@ strict: false # disable strict mode for testing tools: github: mode: local - app: + github-app: app-id: ${{ vars.APP_ID }} private-key: ${{ secrets.APP_PRIVATE_KEY }} repositories: @@ -48,12 +48,12 @@ Test workflow with GitHub MCP Server app configuration. require.NoError(t, err, "Failed to parse markdown content") require.NotNil(t, workflowData.ParsedTools, "ParsedTools should not be nil") require.NotNil(t, workflowData.ParsedTools.GitHub, "GitHub tool should be parsed") - require.NotNil(t, workflowData.ParsedTools.GitHub.App, "App configuration should be parsed") + require.NotNil(t, workflowData.ParsedTools.GitHub.GitHubApp, "App configuration should be parsed") // Verify app configuration - assert.Equal(t, "${{ vars.APP_ID }}", workflowData.ParsedTools.GitHub.App.AppID) - assert.Equal(t, "${{ secrets.APP_PRIVATE_KEY }}", workflowData.ParsedTools.GitHub.App.PrivateKey) - assert.Equal(t, []string{"repo1", "repo2"}, workflowData.ParsedTools.GitHub.App.Repositories) + assert.Equal(t, "${{ vars.APP_ID }}", workflowData.ParsedTools.GitHub.GitHubApp.AppID) + assert.Equal(t, "${{ secrets.APP_PRIVATE_KEY }}", workflowData.ParsedTools.GitHub.GitHubApp.PrivateKey) + assert.Equal(t, []string{"repo1", "repo2"}, workflowData.ParsedTools.GitHub.GitHubApp.Repositories) } // TestGitHubMCPAppTokenMintingStep tests that token minting step is generated @@ -69,7 +69,7 @@ strict: false # disable strict mode for testing tools: github: mode: local - app: + github-app: app-id: ${{ vars.APP_ID }} private-key: ${{ secrets.APP_PRIVATE_KEY }} --- @@ -127,7 +127,7 @@ tools: github: mode: local github-token: ${{ secrets.CUSTOM_GITHUB_TOKEN }} - app: + github-app: app-id: ${{ vars.APP_ID }} private-key: ${{ secrets.APP_PRIVATE_KEY }} --- @@ -160,7 +160,7 @@ permissions: tools: github: mode: remote - app: + github-app: app-id: ${{ vars.APP_ID }} private-key: ${{ secrets.APP_PRIVATE_KEY }} engine: claude @@ -215,7 +215,7 @@ strict: false tools: github: mode: local - app: + github-app: app-id: ${{ vars.APP_ID }} private-key: ${{ secrets.APP_PRIVATE_KEY }} repositories: @@ -270,7 +270,7 @@ strict: false tools: github: mode: local - app: + github-app: app-id: ${{ vars.APP_ID }} private-key: ${{ secrets.APP_PRIVATE_KEY }} repositories: diff --git a/pkg/workflow/mcp_github_config.go b/pkg/workflow/mcp_github_config.go index d1f4ab40478..2ba9965ae2a 100644 --- a/pkg/workflow/mcp_github_config.go +++ b/pkg/workflow/mcp_github_config.go @@ -350,11 +350,11 @@ func (c *Compiler) generateGitHubMCPLockdownDetectionStep(yaml *strings.Builder, // The step mints an installation access token with permissions matching the agent job permissions func (c *Compiler) generateGitHubMCPAppTokenMintingStep(yaml *strings.Builder, data *WorkflowData) { // Check if GitHub tool has app configuration - if data.ParsedTools == nil || data.ParsedTools.GitHub == nil || data.ParsedTools.GitHub.App == nil { + if data.ParsedTools == nil || data.ParsedTools.GitHub == nil || data.ParsedTools.GitHub.GitHubApp == nil { return } - app := data.ParsedTools.GitHub.App + app := data.ParsedTools.GitHub.GitHubApp githubConfigLog.Printf("Generating GitHub App token minting step for GitHub MCP server: app-id=%s", app.AppID) // Get permissions from the agent job - parse from YAML string @@ -382,7 +382,7 @@ func (c *Compiler) generateGitHubMCPAppTokenMintingStep(yaml *strings.Builder, d // This step always runs (even on failure) to ensure tokens are properly cleaned up func (c *Compiler) generateGitHubMCPAppTokenInvalidationStep(yaml *strings.Builder, data *WorkflowData) { // Check if GitHub tool has app configuration - if data.ParsedTools == nil || data.ParsedTools.GitHub == nil || data.ParsedTools.GitHub.App == nil { + if data.ParsedTools == nil || data.ParsedTools.GitHub == nil || data.ParsedTools.GitHub.GitHubApp == nil { return } diff --git a/pkg/workflow/notify_comment.go b/pkg/workflow/notify_comment.go index cc988a3d8c5..2f5adb9474c 100644 --- a/pkg/workflow/notify_comment.go +++ b/pkg/workflow/notify_comment.go @@ -47,10 +47,10 @@ func (c *Compiler) buildConclusionJob(data *WorkflowData, mainJobName string, sa } // Add GitHub App token minting step if app is configured - if data.SafeOutputs.App != nil { + if data.SafeOutputs.GitHubApp != nil { // Compute permissions based on configured safe outputs (principle of least privilege) permissions := ComputePermissionsForSafeOutputs(data.SafeOutputs) - steps = append(steps, c.buildGitHubAppTokenMintStep(data.SafeOutputs.App, permissions)...) + steps = append(steps, c.buildGitHubAppTokenMintStep(data.SafeOutputs.GitHubApp, permissions)...) } // Add artifact download steps once (shared by noop and conclusion steps) @@ -316,7 +316,7 @@ func (c *Compiler) buildConclusionJob(data *WorkflowData, mainJobName string, sa // See buildUnlockJob() in compiler_unlock_job.go // Add GitHub App token invalidation step if app is configured - if data.SafeOutputs.App != nil { + if data.SafeOutputs.GitHubApp != nil { notifyCommentLog.Print("Adding GitHub App token invalidation step to conclusion job") steps = append(steps, c.buildGitHubAppTokenInvalidationStep()...) } diff --git a/pkg/workflow/safe_outputs_app.go b/pkg/workflow/safe_outputs_app.go index a0564d516b9..476e70607a4 100644 --- a/pkg/workflow/safe_outputs_app.go +++ b/pkg/workflow/safe_outputs_app.go @@ -78,9 +78,9 @@ func parseAppConfig(appMap map[string]any) *GitHubAppConfig { func (c *Compiler) mergeAppFromIncludedConfigs(topSafeOutputs *SafeOutputsConfig, includedConfigs []string) (*GitHubAppConfig, error) { safeOutputsAppLog.Printf("Merging app configuration: included_configs=%d", len(includedConfigs)) // If top-level workflow already has app configured, use it (no merge needed) - if topSafeOutputs != nil && topSafeOutputs.App != nil { + if topSafeOutputs != nil && topSafeOutputs.GitHubApp != nil { safeOutputsAppLog.Print("Using top-level app configuration") - return topSafeOutputs.App, nil + return topSafeOutputs.GitHubApp, nil } // Otherwise, find the first app configuration in included configs diff --git a/pkg/workflow/safe_outputs_app_import_test.go b/pkg/workflow/safe_outputs_app_import_test.go index ce975258316..307c74ac915 100644 --- a/pkg/workflow/safe_outputs_app_import_test.go +++ b/pkg/workflow/safe_outputs_app_import_test.go @@ -24,7 +24,7 @@ func TestSafeOutputsAppImport(t *testing.T) { // Create a shared workflow with app configuration sharedWorkflow := `--- safe-outputs: - app: + github-app: app-id: ${{ vars.SHARED_APP_ID }} private-key: ${{ secrets.SHARED_APP_SECRET }} repositories: @@ -71,12 +71,12 @@ This workflow uses the imported app configuration. workflowData, err := compiler.ParseWorkflowFile("main.md") require.NoError(t, err, "Failed to parse workflow") require.NotNil(t, workflowData.SafeOutputs, "SafeOutputs should not be nil") - require.NotNil(t, workflowData.SafeOutputs.App, "App configuration should be imported") + require.NotNil(t, workflowData.SafeOutputs.GitHubApp, "App configuration should be imported") // Verify app configuration was imported correctly - assert.Equal(t, "${{ vars.SHARED_APP_ID }}", workflowData.SafeOutputs.App.AppID) - assert.Equal(t, "${{ secrets.SHARED_APP_SECRET }}", workflowData.SafeOutputs.App.PrivateKey) - assert.Equal(t, []string{"repo1"}, workflowData.SafeOutputs.App.Repositories) + assert.Equal(t, "${{ vars.SHARED_APP_ID }}", workflowData.SafeOutputs.GitHubApp.AppID) + assert.Equal(t, "${{ secrets.SHARED_APP_SECRET }}", workflowData.SafeOutputs.GitHubApp.PrivateKey) + assert.Equal(t, []string{"repo1"}, workflowData.SafeOutputs.GitHubApp.Repositories) } // TestSafeOutputsAppImportOverride tests that local app configuration overrides imported one @@ -92,7 +92,7 @@ func TestSafeOutputsAppImportOverride(t *testing.T) { // Create a shared workflow with app configuration sharedWorkflow := `--- safe-outputs: - app: + github-app: app-id: ${{ vars.SHARED_APP_ID }} private-key: ${{ secrets.SHARED_APP_SECRET }} --- @@ -113,7 +113,7 @@ imports: - ./shared-app.md safe-outputs: create-issue: - app: + github-app: app-id: ${{ vars.LOCAL_APP_ID }} private-key: ${{ secrets.LOCAL_APP_SECRET }} repositories: @@ -140,10 +140,10 @@ This workflow overrides the imported app configuration. workflowData, err := compiler.ParseWorkflowFile("main.md") require.NoError(t, err, "Failed to parse workflow") require.NotNil(t, workflowData.SafeOutputs, "SafeOutputs should not be nil") - require.NotNil(t, workflowData.SafeOutputs.App, "App configuration should be present") + require.NotNil(t, workflowData.SafeOutputs.GitHubApp, "App configuration should be present") // Verify local app configuration takes precedence - assert.Equal(t, "${{ vars.LOCAL_APP_ID }}", workflowData.SafeOutputs.App.AppID) - assert.Equal(t, "${{ secrets.LOCAL_APP_SECRET }}", workflowData.SafeOutputs.App.PrivateKey) - assert.Equal(t, []string{"repo2"}, workflowData.SafeOutputs.App.Repositories) + assert.Equal(t, "${{ vars.LOCAL_APP_ID }}", workflowData.SafeOutputs.GitHubApp.AppID) + assert.Equal(t, "${{ secrets.LOCAL_APP_SECRET }}", workflowData.SafeOutputs.GitHubApp.PrivateKey) + assert.Equal(t, []string{"repo2"}, workflowData.SafeOutputs.GitHubApp.Repositories) } diff --git a/pkg/workflow/safe_outputs_app_test.go b/pkg/workflow/safe_outputs_app_test.go index 698091997e4..2a8716d88b1 100644 --- a/pkg/workflow/safe_outputs_app_test.go +++ b/pkg/workflow/safe_outputs_app_test.go @@ -20,7 +20,7 @@ func TestSafeOutputsAppConfiguration(t *testing.T) { on: issues safe-outputs: create-issue: - app: + github-app: app-id: ${{ vars.APP_ID }} private-key: ${{ secrets.APP_PRIVATE_KEY }} repositories: @@ -42,12 +42,12 @@ Test workflow with app configuration. workflowData, err := compiler.ParseWorkflowFile(testFile) require.NoError(t, err, "Failed to parse markdown content") require.NotNil(t, workflowData.SafeOutputs, "SafeOutputs should not be nil") - require.NotNil(t, workflowData.SafeOutputs.App, "App configuration should be parsed") + require.NotNil(t, workflowData.SafeOutputs.GitHubApp, "App configuration should be parsed") // Verify app configuration - assert.Equal(t, "${{ vars.APP_ID }}", workflowData.SafeOutputs.App.AppID) - assert.Equal(t, "${{ secrets.APP_PRIVATE_KEY }}", workflowData.SafeOutputs.App.PrivateKey) - assert.Equal(t, []string{"repo1", "repo2"}, workflowData.SafeOutputs.App.Repositories) + assert.Equal(t, "${{ vars.APP_ID }}", workflowData.SafeOutputs.GitHubApp.AppID) + assert.Equal(t, "${{ secrets.APP_PRIVATE_KEY }}", workflowData.SafeOutputs.GitHubApp.PrivateKey) + assert.Equal(t, []string{"repo1", "repo2"}, workflowData.SafeOutputs.GitHubApp.Repositories) } // TestSafeOutputsAppConfigurationMinimal tests minimal app configuration without repositories @@ -58,7 +58,7 @@ func TestSafeOutputsAppConfigurationMinimal(t *testing.T) { on: issues safe-outputs: create-issue: - app: + github-app: app-id: ${{ vars.APP_ID }} private-key: ${{ secrets.APP_PRIVATE_KEY }} --- @@ -77,12 +77,12 @@ Test workflow with minimal app configuration. workflowData, err := compiler.ParseWorkflowFile(testFile) require.NoError(t, err, "Failed to parse markdown content") require.NotNil(t, workflowData.SafeOutputs, "SafeOutputs should not be nil") - require.NotNil(t, workflowData.SafeOutputs.App, "App configuration should be parsed") + require.NotNil(t, workflowData.SafeOutputs.GitHubApp, "App configuration should be parsed") // Verify app configuration - assert.Equal(t, "${{ vars.APP_ID }}", workflowData.SafeOutputs.App.AppID) - assert.Equal(t, "${{ secrets.APP_PRIVATE_KEY }}", workflowData.SafeOutputs.App.PrivateKey) - assert.Empty(t, workflowData.SafeOutputs.App.Repositories) + assert.Equal(t, "${{ vars.APP_ID }}", workflowData.SafeOutputs.GitHubApp.AppID) + assert.Equal(t, "${{ secrets.APP_PRIVATE_KEY }}", workflowData.SafeOutputs.GitHubApp.PrivateKey) + assert.Empty(t, workflowData.SafeOutputs.GitHubApp.Repositories) } // TestSafeOutputsAppWithoutSafeOutputs tests that app without safe outputs doesn't break @@ -120,7 +120,7 @@ on: issues safe-outputs: create-discussion: category: "general" - app: + github-app: app-id: ${{ vars.APP_ID }} private-key: ${{ secrets.APP_PRIVATE_KEY }} --- diff --git a/pkg/workflow/safe_outputs_config.go b/pkg/workflow/safe_outputs_config.go index c597df94be1..a044372e3c6 100644 --- a/pkg/workflow/safe_outputs_config.go +++ b/pkg/workflow/safe_outputs_config.go @@ -506,7 +506,7 @@ func (c *Compiler) extractSafeOutputsConfig(frontmatter map[string]any) *SafeOut // Handle app configuration for GitHub App token minting if app, exists := outputMap["github-app"]; exists { if appMap, ok := app.(map[string]any); ok { - config.App = parseAppConfig(appMap) + config.GitHubApp = parseAppConfig(appMap) } } } diff --git a/pkg/workflow/safe_outputs_env.go b/pkg/workflow/safe_outputs_env.go index 16f88d808f3..34395b7292b 100644 --- a/pkg/workflow/safe_outputs_env.go +++ b/pkg/workflow/safe_outputs_env.go @@ -237,7 +237,7 @@ func (c *Compiler) addSafeOutputGitHubTokenForConfig(steps *[]string, data *Work } // If app is configured, use app token - if data.SafeOutputs != nil && data.SafeOutputs.App != nil { + if data.SafeOutputs != nil && data.SafeOutputs.GitHubApp != nil { *steps = append(*steps, " github-token: ${{ steps.safe-outputs-app-token.outputs.token }}\n") return } @@ -262,7 +262,7 @@ func (c *Compiler) addSafeOutputCopilotGitHubTokenForConfig(steps *[]string, dat } // If app is configured, use app token - if data.SafeOutputs != nil && data.SafeOutputs.App != nil { + if data.SafeOutputs != nil && data.SafeOutputs.GitHubApp != nil { *steps = append(*steps, " github-token: ${{ steps.safe-outputs-app-token.outputs.token }}\n") return } @@ -283,7 +283,7 @@ func (c *Compiler) addSafeOutputCopilotGitHubTokenForConfig(steps *[]string, dat // This is specifically for assign-to-agent operations which require elevated permissions. func (c *Compiler) addSafeOutputAgentGitHubTokenForConfig(steps *[]string, data *WorkflowData, configToken string) { // If app is configured, use app token - if data.SafeOutputs != nil && data.SafeOutputs.App != nil { + if data.SafeOutputs != nil && data.SafeOutputs.GitHubApp != nil { *steps = append(*steps, " github-token: ${{ steps.safe-outputs-app-token.outputs.token }}\n") return } diff --git a/pkg/workflow/safe_outputs_jobs.go b/pkg/workflow/safe_outputs_jobs.go index 91fe7a2ba1f..69e8e60cfbd 100644 --- a/pkg/workflow/safe_outputs_jobs.go +++ b/pkg/workflow/safe_outputs_jobs.go @@ -54,9 +54,9 @@ func (c *Compiler) buildSafeOutputJob(data *WorkflowData, config SafeOutputJobCo var steps []string // Add GitHub App token minting step if app is configured - if data.SafeOutputs != nil && data.SafeOutputs.App != nil { + if data.SafeOutputs != nil && data.SafeOutputs.GitHubApp != nil { safeOutputsJobsLog.Print("Adding GitHub App token minting step with auto-computed permissions") - steps = append(steps, c.buildGitHubAppTokenMintStep(data.SafeOutputs.App, config.Permissions)...) + steps = append(steps, c.buildGitHubAppTokenMintStep(data.SafeOutputs.GitHubApp, config.Permissions)...) } // Add pre-steps if provided (e.g., checkout, git config for create-pull-request) @@ -110,7 +110,7 @@ func (c *Compiler) buildSafeOutputJob(data *WorkflowData, config SafeOutputJobCo } // Add GitHub App token invalidation step if app is configured - if data.SafeOutputs != nil && data.SafeOutputs.App != nil { + if data.SafeOutputs != nil && data.SafeOutputs.GitHubApp != nil { safeOutputsJobsLog.Print("Adding GitHub App token invalidation step") steps = append(steps, c.buildGitHubAppTokenInvalidationStep()...) } diff --git a/pkg/workflow/tools_parser.go b/pkg/workflow/tools_parser.go index 525be40afd8..81305ecaa8b 100644 --- a/pkg/workflow/tools_parser.go +++ b/pkg/workflow/tools_parser.go @@ -232,7 +232,7 @@ func parseGitHubTool(val any) *GitHubToolConfig { // Parse app configuration for GitHub App token minting if rawApp, exists := configMap["github-app"]; exists { if appMap, ok := rawApp.(map[string]any); ok { - config.App = parseAppConfig(appMap) + config.GitHubApp = parseAppConfig(appMap) } } diff --git a/pkg/workflow/tools_types.go b/pkg/workflow/tools_types.go index ef3e148cc8b..b35f6f69027 100644 --- a/pkg/workflow/tools_types.go +++ b/pkg/workflow/tools_types.go @@ -291,7 +291,7 @@ type GitHubToolConfig struct { GitHubToken string `yaml:"github-token,omitempty"` Toolset GitHubToolsets `yaml:"toolsets,omitempty"` Lockdown bool `yaml:"lockdown,omitempty"` - App *GitHubAppConfig `yaml:"app,omitempty"` // GitHub App configuration for token minting + GitHubApp *GitHubAppConfig `yaml:"github-app,omitempty"` // GitHub App configuration for token minting // Guard policy fields (flat syntax under github:) // Repos defines the access scope for policy enforcement. diff --git a/pkg/workflow/tools_validation.go b/pkg/workflow/tools_validation.go index 16d02f93f42..445e59bbf13 100644 --- a/pkg/workflow/tools_validation.go +++ b/pkg/workflow/tools_validation.go @@ -94,7 +94,7 @@ func validateGitHubToolConfig(tools *Tools, workflowName string) error { return nil } - if tools.GitHub.App != nil && tools.GitHub.GitHubToken != "" { + if tools.GitHub.GitHubApp != nil && tools.GitHub.GitHubToken != "" { toolsValidationLog.Printf("Invalid GitHub tool configuration in workflow: %s", workflowName) return errors.New("invalid GitHub tool configuration: 'tools.github.github-app' and 'tools.github.github-token' cannot both be set. Use one authentication method: either 'github-app' (GitHub App) or 'github-token' (personal access token)") } From eff7f79ec12795d22b1d656bac7ec48c283fff91 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 3 Mar 2026 02:52:53 +0000 Subject: [PATCH 07/10] Add changeset [skip-ci] --- .changeset/minor-rename-app-to-github-app.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/minor-rename-app-to-github-app.md diff --git a/.changeset/minor-rename-app-to-github-app.md b/.changeset/minor-rename-app-to-github-app.md new file mode 100644 index 00000000000..197007e0336 --- /dev/null +++ b/.changeset/minor-rename-app-to-github-app.md @@ -0,0 +1,5 @@ +--- +"gh-aw": minor +--- + +Renamed the deprecated `app:` workflow field to `github-app:` and added the codemod plus schema/Go updates to keep tooling in sync. From a5f1ebb86c59ca1b3cfebf88bf807927d447f35f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 3 Mar 2026 02:54:00 +0000 Subject: [PATCH 08/10] ci: trigger checks From 29d8b469b5cef4568108ca8480bed0c1cecf6453 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 3 Mar 2026 03:27:51 +0000 Subject: [PATCH 09/10] Fix lint issues: use errors.New and strings.SplitSeq Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/cli/codemod_github_app_test.go | 2 +- pkg/workflow/checkout_manager.go | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/cli/codemod_github_app_test.go b/pkg/cli/codemod_github_app_test.go index 84d07f37eb9..63ffe143290 100644 --- a/pkg/cli/codemod_github_app_test.go +++ b/pkg/cli/codemod_github_app_test.go @@ -13,7 +13,7 @@ import ( // hasDeprecatedAppFieldInContent returns true if any line in the content has 'app:' as its YAML key // (i.e., trimmed content starts with "app:" – matches the field name, not app-id: or github-app:) func hasDeprecatedAppFieldInContent(content string) bool { - for _, line := range strings.Split(content, "\n") { + for line := range strings.SplitSeq(content, "\n") { trimmed := strings.TrimSpace(line) if trimmed == "app:" || strings.HasPrefix(trimmed, "app: ") || strings.HasPrefix(trimmed, "app:\t") { return true diff --git a/pkg/workflow/checkout_manager.go b/pkg/workflow/checkout_manager.go index 240977d8d5c..75f1c339baa 100644 --- a/pkg/workflow/checkout_manager.go +++ b/pkg/workflow/checkout_manager.go @@ -726,11 +726,11 @@ func checkoutConfigFromMap(m map[string]any) (*CheckoutConfig, error) { if v, ok := m["github-app"]; ok { appMap, ok := v.(map[string]any) if !ok { - return nil, fmt.Errorf("checkout.github-app must be an object") + return nil, errors.New("checkout.github-app must be an object") } cfg.GitHubApp = parseAppConfig(appMap) if cfg.GitHubApp.AppID == "" || cfg.GitHubApp.PrivateKey == "" { - return nil, fmt.Errorf("checkout.github-app requires both app-id and private-key") + return nil, errors.New("checkout.github-app requires both app-id and private-key") } } From e0c750439fb54b177a14ebd920117abcfc75bd0f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 3 Mar 2026 04:04:26 +0000 Subject: [PATCH 10/10] Fix test failures: add github-app to safeOutputMetaFields and merge GitHubApp in imports Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/parser/schema_compiler.go | 2 +- pkg/parser/schema_test.go | 2 +- pkg/workflow/github_mcp_app_token_test.go | 2 +- pkg/workflow/imports.go | 3 +++ 4 files changed, 6 insertions(+), 3 deletions(-) diff --git a/pkg/parser/schema_compiler.go b/pkg/parser/schema_compiler.go index f4d78872217..97e10c1670b 100644 --- a/pkg/parser/schema_compiler.go +++ b/pkg/parser/schema_compiler.go @@ -87,7 +87,7 @@ var safeOutputMetaFields = map[string]bool{ "staged": true, "env": true, "github-token": true, - "app": true, + "github-app": true, "max-patch-size": true, "jobs": true, "runs-on": true, diff --git a/pkg/parser/schema_test.go b/pkg/parser/schema_test.go index a9aaa1f6695..515df3720a3 100644 --- a/pkg/parser/schema_test.go +++ b/pkg/parser/schema_test.go @@ -174,7 +174,7 @@ func TestGetSafeOutputTypeKeys(t *testing.T) { "staged", "env", "github-token", - "app", + "github-app", "max-patch-size", "jobs", "runs-on", diff --git a/pkg/workflow/github_mcp_app_token_test.go b/pkg/workflow/github_mcp_app_token_test.go index 1aff591c468..0a55ddeb1a7 100644 --- a/pkg/workflow/github_mcp_app_token_test.go +++ b/pkg/workflow/github_mcp_app_token_test.go @@ -146,7 +146,7 @@ Test that setting both app and github-token is an error. // Compile the workflow - should fail because both app and github-token are set err = compiler.CompileWorkflow(testFile) require.Error(t, err, "Expected error when both app and github-token are set") - assert.Contains(t, err.Error(), "'tools.github.app' and 'tools.github.github-token' cannot both be set", "Error should mention mutual exclusion") + assert.Contains(t, err.Error(), "'tools.github.github-app' and 'tools.github.github-token' cannot both be set", "Error should mention mutual exclusion") } // TestGitHubMCPAppTokenWithRemoteMode tests that app token works with remote mode diff --git a/pkg/workflow/imports.go b/pkg/workflow/imports.go index 17acdb5b989..b44fb77ab3c 100644 --- a/pkg/workflow/imports.go +++ b/pkg/workflow/imports.go @@ -656,6 +656,9 @@ func mergeSafeOutputConfig(result *SafeOutputsConfig, config map[string]any, c * if result.GitHubToken == "" && importedConfig.GitHubToken != "" { result.GitHubToken = importedConfig.GitHubToken } + if result.GitHubApp == nil && importedConfig.GitHubApp != nil { + result.GitHubApp = importedConfig.GitHubApp + } if result.MaximumPatchSize == 0 && importedConfig.MaximumPatchSize > 0 { result.MaximumPatchSize = importedConfig.MaximumPatchSize }