From 66b1a7f3dad1ae70fbbb74a6bbee2a10d43c6bf5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Mar 2026 06:30:51 +0000 Subject: [PATCH 1/3] Initial plan From e04f78d79bcfe095277e223ca8c7d370f9670b9a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Mar 2026 06:42:52 +0000 Subject: [PATCH 2/3] refactor: split safe_outputs_config.go into focused modules - Extract GitHub App config/token generation into safe_outputs_app_config.go (341 lines) - Extract messages/mentions parsing into safe_outputs_messages_config.go (153 lines) - Reduce safe_outputs_config.go from 1077 to 605 lines - All public APIs preserved; no behavioral changes - Build and tests pass Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/workflow/safe_outputs_app_config.go | 341 +++++++++++++ pkg/workflow/safe_outputs_config.go | 480 ------------------- pkg/workflow/safe_outputs_messages_config.go | 153 ++++++ 3 files changed, 494 insertions(+), 480 deletions(-) create mode 100644 pkg/workflow/safe_outputs_app_config.go create mode 100644 pkg/workflow/safe_outputs_messages_config.go diff --git a/pkg/workflow/safe_outputs_app_config.go b/pkg/workflow/safe_outputs_app_config.go new file mode 100644 index 00000000000..efbb5293a08 --- /dev/null +++ b/pkg/workflow/safe_outputs_app_config.go @@ -0,0 +1,341 @@ +package workflow + +import ( + "encoding/json" + "fmt" + "sort" + + "github.com/github/gh-aw/pkg/logger" +) + +var safeOutputsAppLog = logger.New("workflow:safe_outputs_app") + +// ======================================== +// GitHub App Configuration +// ======================================== + +// GitHubAppConfig holds configuration for GitHub App-based token minting +type GitHubAppConfig struct { + AppID string `yaml:"app-id,omitempty"` // GitHub App ID (e.g., "${{ vars.APP_ID }}") + PrivateKey string `yaml:"private-key,omitempty"` // GitHub App private key (e.g., "${{ secrets.APP_PRIVATE_KEY }}") + Owner string `yaml:"owner,omitempty"` // Optional: owner of the GitHub App installation (defaults to current repository owner) + Repositories []string `yaml:"repositories,omitempty"` // Optional: comma or newline-separated list of repositories to grant access to +} + +// ======================================== +// App Configuration Parsing +// ======================================== + +// parseAppConfig parses the app configuration from a map +func parseAppConfig(appMap map[string]any) *GitHubAppConfig { + safeOutputsAppLog.Print("Parsing GitHub App configuration") + appConfig := &GitHubAppConfig{} + + // Parse app-id (required) + if appID, exists := appMap["app-id"]; exists { + if appIDStr, ok := appID.(string); ok { + appConfig.AppID = appIDStr + } + } + + // Parse private-key (required) + if privateKey, exists := appMap["private-key"]; exists { + if privateKeyStr, ok := privateKey.(string); ok { + appConfig.PrivateKey = privateKeyStr + } + } + + // Parse owner (optional) + if owner, exists := appMap["owner"]; exists { + if ownerStr, ok := owner.(string); ok { + appConfig.Owner = ownerStr + } + } + + // Parse repositories (optional) + if repos, exists := appMap["repositories"]; exists { + if reposArray, ok := repos.([]any); ok { + var repoStrings []string + for _, repo := range reposArray { + if repoStr, ok := repo.(string); ok { + repoStrings = append(repoStrings, repoStr) + } + } + appConfig.Repositories = repoStrings + } + } + + return appConfig +} + +// ======================================== +// App Configuration Merging +// ======================================== + +// mergeAppFromIncludedConfigs merges app configuration from included safe-outputs configurations +// If the top-level workflow has an app configured, it takes precedence +// Otherwise, the first app configuration found in included configs is used +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.GitHubApp != nil { + safeOutputsAppLog.Print("Using top-level app configuration") + return topSafeOutputs.GitHubApp, nil + } + + // Otherwise, find the first app configuration in included configs + for _, configJSON := range includedConfigs { + if configJSON == "" || configJSON == "{}" { + continue + } + + // Parse the safe-outputs configuration + var safeOutputsConfig map[string]any + if err := json.Unmarshal([]byte(configJSON), &safeOutputsConfig); err != nil { + continue // Skip invalid JSON + } + + // 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) + + // Return first valid app configuration found + if appConfig.AppID != "" && appConfig.PrivateKey != "" { + safeOutputsAppLog.Print("Found valid app configuration in included config") + return appConfig, nil + } + } + } + } + + safeOutputsAppLog.Print("No app configuration found in included configs") + return nil, nil +} + +// ======================================== +// GitHub App Token Steps Generation +// ======================================== + +// buildGitHubAppTokenMintStep generates the step to mint a GitHub App installation access token +// Permissions are automatically computed from the safe output job requirements. +// fallbackRepoExpr overrides the default ${{ github.event.repository.name }} fallback when +// no explicit repositories are configured (e.g. pass needs.activation.outputs.target_repo for +// workflow_call relay workflows so the token is scoped to the platform repo, not the caller's). +func (c *Compiler) buildGitHubAppTokenMintStep(app *GitHubAppConfig, permissions *Permissions, fallbackRepoExpr string) []string { + safeOutputsAppLog.Printf("Building GitHub App token mint step: owner=%s, repos=%d", app.Owner, len(app.Repositories)) + var steps []string + + steps = append(steps, " - name: Generate GitHub App token\n") + steps = append(steps, " id: safe-outputs-app-token\n") + steps = append(steps, fmt.Sprintf(" uses: %s\n", GetActionPin("actions/create-github-app-token"))) + steps = append(steps, " with:\n") + steps = append(steps, fmt.Sprintf(" app-id: %s\n", app.AppID)) + steps = append(steps, fmt.Sprintf(" private-key: %s\n", app.PrivateKey)) + + // Add owner - default to current repository owner if not specified + owner := app.Owner + if owner == "" { + owner = "${{ github.repository_owner }}" + } + steps = append(steps, fmt.Sprintf(" owner: %s\n", owner)) + + // Add repositories - behavior depends on configuration: + // - If repositories is ["*"], omit the field to allow org-wide access + // - If repositories is a single value, use inline format + // - If repositories has multiple values, use block scalar format (newline-separated) + // to ensure clarity and proper parsing by actions/create-github-app-token + // - If repositories is empty/not specified, default to fallbackRepoExpr or the current repository + if len(app.Repositories) == 1 && app.Repositories[0] == "*" { + // Org-wide access: omit repositories field entirely + safeOutputsAppLog.Print("Using org-wide GitHub App token (repositories: *)") + } else if len(app.Repositories) == 1 { + // Single repository: use inline format for clarity + steps = append(steps, fmt.Sprintf(" repositories: %s\n", app.Repositories[0])) + } else if len(app.Repositories) > 1 { + // Multiple repositories: use block scalar format (newline-separated) + // This format is more readable and avoids potential issues with comma-separated parsing + steps = append(steps, " repositories: |-\n") + for _, repo := range app.Repositories { + steps = append(steps, fmt.Sprintf(" %s\n", repo)) + } + } else { + // No explicit repositories: use fallback expression, or default to the triggering repo's name. + // For workflow_call relay scenarios the caller passes needs.activation.outputs.target_repo so + // the token is scoped to the platform (host) repo rather than the caller repo. + repoExpr := fallbackRepoExpr + if repoExpr == "" { + repoExpr = "${{ github.event.repository.name }}" + } + steps = append(steps, fmt.Sprintf(" repositories: %s\n", repoExpr)) + } + + // Always add github-api-url from environment variable + steps = append(steps, " github-api-url: ${{ github.api_url }}\n") + + // Add permission-* fields automatically computed from job permissions + // Sort keys to ensure deterministic compilation order + if permissions != nil { + permissionFields := convertPermissionsToAppTokenFields(permissions) + + // Extract and sort keys for deterministic ordering + keys := make([]string, 0, len(permissionFields)) + for key := range permissionFields { + keys = append(keys, key) + } + sort.Strings(keys) + + // Add permissions in sorted order + for _, key := range keys { + steps = append(steps, fmt.Sprintf(" %s: %s\n", key, permissionFields[key])) + } + } + + return steps +} + +// convertPermissionsToAppTokenFields converts job Permissions to permission-* action inputs +// This follows GitHub's recommendation for explicit permission control +// Note: This only includes permissions that are valid for GitHub App tokens. +// Some GitHub Actions permissions (like 'discussions', 'models') don't have +// corresponding GitHub App permissions and are skipped. +func convertPermissionsToAppTokenFields(permissions *Permissions) map[string]string { + fields := make(map[string]string) + + // Map GitHub Actions permissions to GitHub App permissions + // Only include permissions that exist in the actions/create-github-app-token action + // See: https://github.com/actions/create-github-app-token#permissions + + // Repository permissions that map directly + if level, ok := permissions.Get(PermissionActions); ok { + fields["permission-actions"] = string(level) + } + if level, ok := permissions.Get(PermissionChecks); ok { + fields["permission-checks"] = string(level) + } + if level, ok := permissions.Get(PermissionContents); ok { + fields["permission-contents"] = string(level) + } + if level, ok := permissions.Get(PermissionDeployments); ok { + fields["permission-deployments"] = string(level) + } + if level, ok := permissions.Get(PermissionIssues); ok { + fields["permission-issues"] = string(level) + } + if level, ok := permissions.Get(PermissionPackages); ok { + fields["permission-packages"] = string(level) + } + if level, ok := permissions.Get(PermissionPages); ok { + fields["permission-pages"] = string(level) + } + if level, ok := permissions.Get(PermissionPullRequests); ok { + fields["permission-pull-requests"] = string(level) + } + if level, ok := permissions.Get(PermissionSecurityEvents); ok { + fields["permission-security-events"] = string(level) + } + if level, ok := permissions.Get(PermissionStatuses); ok { + fields["permission-statuses"] = string(level) + } + if level, ok := permissions.Get(PermissionOrganizationProj); ok { + fields["permission-organization-projects"] = string(level) + } + if level, ok := permissions.Get(PermissionDiscussions); ok { + fields["permission-discussions"] = string(level) + } + + // Note: The following GitHub Actions permissions do NOT have GitHub App equivalents: + // - models (no GitHub App permission for this) + // - id-token (not applicable to GitHub Apps) + // - attestations (no GitHub App permission for this) + // - repository-projects (removed - classic projects are sunset; use organization-projects for Projects v2 via PAT/GitHub App) + + return fields +} + +// buildGitHubAppTokenInvalidationStep generates the step to invalidate the GitHub App token +// This step always runs (even on failure) to ensure tokens are properly cleaned up +// Only runs if a token was successfully minted +func (c *Compiler) buildGitHubAppTokenInvalidationStep() []string { + var steps []string + + steps = append(steps, " - name: Invalidate GitHub App token\n") + steps = append(steps, " if: always() && steps.safe-outputs-app-token.outputs.token != ''\n") + steps = append(steps, " env:\n") + steps = append(steps, " TOKEN: ${{ steps.safe-outputs-app-token.outputs.token }}\n") + steps = append(steps, " run: |\n") + steps = append(steps, " echo \"Revoking GitHub App installation token...\"\n") + steps = append(steps, " # GitHub CLI will auth with the token being revoked.\n") + steps = append(steps, " gh api \\\n") + steps = append(steps, " --method DELETE \\\n") + steps = append(steps, " -H \"Authorization: token $TOKEN\" \\\n") + steps = append(steps, " /installation/token || echo \"Token revoke may already be expired.\"\n") + steps = append(steps, " \n") + steps = append(steps, " echo \"Token invalidation step complete.\"\n") + + return steps +} + +// ======================================== +// Activation Token Steps Generation +// ======================================== + +// buildActivationAppTokenMintStep generates the step to mint a GitHub App installation access token +// for use in the pre-activation (reaction) and activation (status comment) jobs. +func (c *Compiler) buildActivationAppTokenMintStep(app *GitHubAppConfig, permissions *Permissions) []string { + safeOutputsAppLog.Printf("Building activation GitHub App token mint step: owner=%s", app.Owner) + var steps []string + + steps = append(steps, " - name: Generate GitHub App token for activation\n") + steps = append(steps, " id: activation-app-token\n") + steps = append(steps, fmt.Sprintf(" uses: %s\n", GetActionPin("actions/create-github-app-token"))) + steps = append(steps, " with:\n") + steps = append(steps, fmt.Sprintf(" app-id: %s\n", app.AppID)) + steps = append(steps, fmt.Sprintf(" private-key: %s\n", app.PrivateKey)) + + // Add owner - default to current repository owner if not specified + owner := app.Owner + if owner == "" { + owner = "${{ github.repository_owner }}" + } + steps = append(steps, fmt.Sprintf(" owner: %s\n", owner)) + + // Default to current repository + steps = append(steps, " repositories: ${{ github.event.repository.name }}\n") + + // Always add github-api-url from environment variable + steps = append(steps, " github-api-url: ${{ github.api_url }}\n") + + // Add permission-* fields automatically computed from job permissions + if permissions != nil { + permissionFields := convertPermissionsToAppTokenFields(permissions) + + keys := make([]string, 0, len(permissionFields)) + for key := range permissionFields { + keys = append(keys, key) + } + sort.Strings(keys) + + for _, key := range keys { + steps = append(steps, fmt.Sprintf(" %s: %s\n", key, permissionFields[key])) + } + } + + return steps +} + +// resolveActivationToken returns the GitHub token to use for activation steps (reactions, status comments). +// Priority: GitHub App minted token > custom github-token > GITHUB_TOKEN (default) +// +// When returning the app token reference, callers MUST ensure that buildActivationAppTokenMintStep +// has already been called to generate the 'activation-app-token' step, since this function returns +// a reference to that step's output (${{ steps.activation-app-token.outputs.token }}). +func (c *Compiler) resolveActivationToken(data *WorkflowData) string { + if data.ActivationGitHubApp != nil { + return "${{ steps.activation-app-token.outputs.token }}" + } + if data.ActivationGitHubToken != "" { + return data.ActivationGitHubToken + } + return "${{ secrets.GITHUB_TOKEN }}" +} diff --git a/pkg/workflow/safe_outputs_config.go b/pkg/workflow/safe_outputs_config.go index 38f489c69d0..28a99682041 100644 --- a/pkg/workflow/safe_outputs_config.go +++ b/pkg/workflow/safe_outputs_config.go @@ -1,9 +1,6 @@ package workflow import ( - "encoding/json" - "fmt" - "sort" "strings" "github.com/github/gh-aw/pkg/logger" @@ -606,480 +603,3 @@ func (c *Compiler) parseBaseSafeOutputConfig(configMap map[string]any, config *B } } } - -var safeOutputsAppLog = logger.New("workflow:safe_outputs_app") - -// ======================================== -// GitHub App Configuration -// ======================================== - -// GitHubAppConfig holds configuration for GitHub App-based token minting -type GitHubAppConfig struct { - AppID string `yaml:"app-id,omitempty"` // GitHub App ID (e.g., "${{ vars.APP_ID }}") - PrivateKey string `yaml:"private-key,omitempty"` // GitHub App private key (e.g., "${{ secrets.APP_PRIVATE_KEY }}") - Owner string `yaml:"owner,omitempty"` // Optional: owner of the GitHub App installation (defaults to current repository owner) - Repositories []string `yaml:"repositories,omitempty"` // Optional: comma or newline-separated list of repositories to grant access to -} - -// ======================================== -// App Configuration Parsing -// ======================================== - -// parseAppConfig parses the app configuration from a map -func parseAppConfig(appMap map[string]any) *GitHubAppConfig { - safeOutputsAppLog.Print("Parsing GitHub App configuration") - appConfig := &GitHubAppConfig{} - - // Parse app-id (required) - if appID, exists := appMap["app-id"]; exists { - if appIDStr, ok := appID.(string); ok { - appConfig.AppID = appIDStr - } - } - - // Parse private-key (required) - if privateKey, exists := appMap["private-key"]; exists { - if privateKeyStr, ok := privateKey.(string); ok { - appConfig.PrivateKey = privateKeyStr - } - } - - // Parse owner (optional) - if owner, exists := appMap["owner"]; exists { - if ownerStr, ok := owner.(string); ok { - appConfig.Owner = ownerStr - } - } - - // Parse repositories (optional) - if repos, exists := appMap["repositories"]; exists { - if reposArray, ok := repos.([]any); ok { - var repoStrings []string - for _, repo := range reposArray { - if repoStr, ok := repo.(string); ok { - repoStrings = append(repoStrings, repoStr) - } - } - appConfig.Repositories = repoStrings - } - } - - return appConfig -} - -// ======================================== -// App Configuration Merging -// ======================================== - -// mergeAppFromIncludedConfigs merges app configuration from included safe-outputs configurations -// If the top-level workflow has an app configured, it takes precedence -// Otherwise, the first app configuration found in included configs is used -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.GitHubApp != nil { - safeOutputsAppLog.Print("Using top-level app configuration") - return topSafeOutputs.GitHubApp, nil - } - - // Otherwise, find the first app configuration in included configs - for _, configJSON := range includedConfigs { - if configJSON == "" || configJSON == "{}" { - continue - } - - // Parse the safe-outputs configuration - var safeOutputsConfig map[string]any - if err := json.Unmarshal([]byte(configJSON), &safeOutputsConfig); err != nil { - continue // Skip invalid JSON - } - - // 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) - - // Return first valid app configuration found - if appConfig.AppID != "" && appConfig.PrivateKey != "" { - safeOutputsAppLog.Print("Found valid app configuration in included config") - return appConfig, nil - } - } - } - } - - safeOutputsAppLog.Print("No app configuration found in included configs") - return nil, nil -} - -// ======================================== -// GitHub App Token Steps Generation -// ======================================== - -// buildGitHubAppTokenMintStep generates the step to mint a GitHub App installation access token -// Permissions are automatically computed from the safe output job requirements. -// fallbackRepoExpr overrides the default ${{ github.event.repository.name }} fallback when -// no explicit repositories are configured (e.g. pass needs.activation.outputs.target_repo for -// workflow_call relay workflows so the token is scoped to the platform repo, not the caller's). -func (c *Compiler) buildGitHubAppTokenMintStep(app *GitHubAppConfig, permissions *Permissions, fallbackRepoExpr string) []string { - safeOutputsAppLog.Printf("Building GitHub App token mint step: owner=%s, repos=%d", app.Owner, len(app.Repositories)) - var steps []string - - steps = append(steps, " - name: Generate GitHub App token\n") - steps = append(steps, " id: safe-outputs-app-token\n") - steps = append(steps, fmt.Sprintf(" uses: %s\n", GetActionPin("actions/create-github-app-token"))) - steps = append(steps, " with:\n") - steps = append(steps, fmt.Sprintf(" app-id: %s\n", app.AppID)) - steps = append(steps, fmt.Sprintf(" private-key: %s\n", app.PrivateKey)) - - // Add owner - default to current repository owner if not specified - owner := app.Owner - if owner == "" { - owner = "${{ github.repository_owner }}" - } - steps = append(steps, fmt.Sprintf(" owner: %s\n", owner)) - - // Add repositories - behavior depends on configuration: - // - If repositories is ["*"], omit the field to allow org-wide access - // - If repositories is a single value, use inline format - // - If repositories has multiple values, use block scalar format (newline-separated) - // to ensure clarity and proper parsing by actions/create-github-app-token - // - If repositories is empty/not specified, default to fallbackRepoExpr or the current repository - if len(app.Repositories) == 1 && app.Repositories[0] == "*" { - // Org-wide access: omit repositories field entirely - safeOutputsAppLog.Print("Using org-wide GitHub App token (repositories: *)") - } else if len(app.Repositories) == 1 { - // Single repository: use inline format for clarity - steps = append(steps, fmt.Sprintf(" repositories: %s\n", app.Repositories[0])) - } else if len(app.Repositories) > 1 { - // Multiple repositories: use block scalar format (newline-separated) - // This format is more readable and avoids potential issues with comma-separated parsing - steps = append(steps, " repositories: |-\n") - for _, repo := range app.Repositories { - steps = append(steps, fmt.Sprintf(" %s\n", repo)) - } - } else { - // No explicit repositories: use fallback expression, or default to the triggering repo's name. - // For workflow_call relay scenarios the caller passes needs.activation.outputs.target_repo so - // the token is scoped to the platform (host) repo rather than the caller repo. - repoExpr := fallbackRepoExpr - if repoExpr == "" { - repoExpr = "${{ github.event.repository.name }}" - } - steps = append(steps, fmt.Sprintf(" repositories: %s\n", repoExpr)) - } - - // Always add github-api-url from environment variable - steps = append(steps, " github-api-url: ${{ github.api_url }}\n") - - // Add permission-* fields automatically computed from job permissions - // Sort keys to ensure deterministic compilation order - if permissions != nil { - permissionFields := convertPermissionsToAppTokenFields(permissions) - - // Extract and sort keys for deterministic ordering - keys := make([]string, 0, len(permissionFields)) - for key := range permissionFields { - keys = append(keys, key) - } - sort.Strings(keys) - - // Add permissions in sorted order - for _, key := range keys { - steps = append(steps, fmt.Sprintf(" %s: %s\n", key, permissionFields[key])) - } - } - - return steps -} - -// convertPermissionsToAppTokenFields converts job Permissions to permission-* action inputs -// This follows GitHub's recommendation for explicit permission control -// Note: This only includes permissions that are valid for GitHub App tokens. -// Some GitHub Actions permissions (like 'discussions', 'models') don't have -// corresponding GitHub App permissions and are skipped. -func convertPermissionsToAppTokenFields(permissions *Permissions) map[string]string { - fields := make(map[string]string) - - // Map GitHub Actions permissions to GitHub App permissions - // Only include permissions that exist in the actions/create-github-app-token action - // See: https://github.com/actions/create-github-app-token#permissions - - // Repository permissions that map directly - if level, ok := permissions.Get(PermissionActions); ok { - fields["permission-actions"] = string(level) - } - if level, ok := permissions.Get(PermissionChecks); ok { - fields["permission-checks"] = string(level) - } - if level, ok := permissions.Get(PermissionContents); ok { - fields["permission-contents"] = string(level) - } - if level, ok := permissions.Get(PermissionDeployments); ok { - fields["permission-deployments"] = string(level) - } - if level, ok := permissions.Get(PermissionIssues); ok { - fields["permission-issues"] = string(level) - } - if level, ok := permissions.Get(PermissionPackages); ok { - fields["permission-packages"] = string(level) - } - if level, ok := permissions.Get(PermissionPages); ok { - fields["permission-pages"] = string(level) - } - if level, ok := permissions.Get(PermissionPullRequests); ok { - fields["permission-pull-requests"] = string(level) - } - if level, ok := permissions.Get(PermissionSecurityEvents); ok { - fields["permission-security-events"] = string(level) - } - if level, ok := permissions.Get(PermissionStatuses); ok { - fields["permission-statuses"] = string(level) - } - if level, ok := permissions.Get(PermissionOrganizationProj); ok { - fields["permission-organization-projects"] = string(level) - } - if level, ok := permissions.Get(PermissionDiscussions); ok { - fields["permission-discussions"] = string(level) - } - - // Note: The following GitHub Actions permissions do NOT have GitHub App equivalents: - // - models (no GitHub App permission for this) - // - id-token (not applicable to GitHub Apps) - // - attestations (no GitHub App permission for this) - // - repository-projects (removed - classic projects are sunset; use organization-projects for Projects v2 via PAT/GitHub App) - - return fields -} - -// buildGitHubAppTokenInvalidationStep generates the step to invalidate the GitHub App token -// This step always runs (even on failure) to ensure tokens are properly cleaned up -// Only runs if a token was successfully minted -func (c *Compiler) buildGitHubAppTokenInvalidationStep() []string { - var steps []string - - steps = append(steps, " - name: Invalidate GitHub App token\n") - steps = append(steps, " if: always() && steps.safe-outputs-app-token.outputs.token != ''\n") - steps = append(steps, " env:\n") - steps = append(steps, " TOKEN: ${{ steps.safe-outputs-app-token.outputs.token }}\n") - steps = append(steps, " run: |\n") - steps = append(steps, " echo \"Revoking GitHub App installation token...\"\n") - steps = append(steps, " # GitHub CLI will auth with the token being revoked.\n") - steps = append(steps, " gh api \\\n") - steps = append(steps, " --method DELETE \\\n") - steps = append(steps, " -H \"Authorization: token $TOKEN\" \\\n") - steps = append(steps, " /installation/token || echo \"Token revoke may already be expired.\"\n") - steps = append(steps, " \n") - steps = append(steps, " echo \"Token invalidation step complete.\"\n") - - return steps -} - -// ======================================== -// Activation Token Steps Generation -// ======================================== - -// buildActivationAppTokenMintStep generates the step to mint a GitHub App installation access token -// for use in the pre-activation (reaction) and activation (status comment) jobs. -func (c *Compiler) buildActivationAppTokenMintStep(app *GitHubAppConfig, permissions *Permissions) []string { - safeOutputsAppLog.Printf("Building activation GitHub App token mint step: owner=%s", app.Owner) - var steps []string - - steps = append(steps, " - name: Generate GitHub App token for activation\n") - steps = append(steps, " id: activation-app-token\n") - steps = append(steps, fmt.Sprintf(" uses: %s\n", GetActionPin("actions/create-github-app-token"))) - steps = append(steps, " with:\n") - steps = append(steps, fmt.Sprintf(" app-id: %s\n", app.AppID)) - steps = append(steps, fmt.Sprintf(" private-key: %s\n", app.PrivateKey)) - - // Add owner - default to current repository owner if not specified - owner := app.Owner - if owner == "" { - owner = "${{ github.repository_owner }}" - } - steps = append(steps, fmt.Sprintf(" owner: %s\n", owner)) - - // Default to current repository - steps = append(steps, " repositories: ${{ github.event.repository.name }}\n") - - // Always add github-api-url from environment variable - steps = append(steps, " github-api-url: ${{ github.api_url }}\n") - - // Add permission-* fields automatically computed from job permissions - if permissions != nil { - permissionFields := convertPermissionsToAppTokenFields(permissions) - - keys := make([]string, 0, len(permissionFields)) - for key := range permissionFields { - keys = append(keys, key) - } - sort.Strings(keys) - - for _, key := range keys { - steps = append(steps, fmt.Sprintf(" %s: %s\n", key, permissionFields[key])) - } - } - - return steps -} - -// resolveActivationToken returns the GitHub token to use for activation steps (reactions, status comments). -// Priority: GitHub App minted token > custom github-token > GITHUB_TOKEN (default) -// -// When returning the app token reference, callers MUST ensure that buildActivationAppTokenMintStep -// has already been called to generate the 'activation-app-token' step, since this function returns -// a reference to that step's output (${{ steps.activation-app-token.outputs.token }}). -func (c *Compiler) resolveActivationToken(data *WorkflowData) string { - if data.ActivationGitHubApp != nil { - return "${{ steps.activation-app-token.outputs.token }}" - } - if data.ActivationGitHubToken != "" { - return data.ActivationGitHubToken - } - return "${{ secrets.GITHUB_TOKEN }}" -} - -var safeOutputMessagesLog = logger.New("workflow:safe_outputs_config_messages") - -// ======================================== -// Safe Output Messages Configuration -// ======================================== - -// setStringFromMap reads m[key] and assigns its string value to *dest if found. -func setStringFromMap(m map[string]any, key string, dest *string) { - if val, exists := m[key]; exists { - if str, ok := val.(string); ok { - *dest = str - } - } -} - -// parseMessagesConfig parses the messages configuration from safe-outputs frontmatter -func parseMessagesConfig(messagesMap map[string]any) *SafeOutputMessagesConfig { - safeOutputMessagesLog.Printf("Parsing messages configuration with %d fields", len(messagesMap)) - config := &SafeOutputMessagesConfig{} - - if appendOnly, exists := messagesMap["append-only-comments"]; exists { - if appendOnlyBool, ok := appendOnly.(bool); ok { - config.AppendOnlyComments = appendOnlyBool - safeOutputMessagesLog.Printf("Set append-only-comments: %t", appendOnlyBool) - } - } - - setStringFromMap(messagesMap, "footer", &config.Footer) - setStringFromMap(messagesMap, "footer-install", &config.FooterInstall) - setStringFromMap(messagesMap, "footer-workflow-recompile", &config.FooterWorkflowRecompile) - setStringFromMap(messagesMap, "footer-workflow-recompile-comment", &config.FooterWorkflowRecompileComment) - setStringFromMap(messagesMap, "staged-title", &config.StagedTitle) - setStringFromMap(messagesMap, "staged-description", &config.StagedDescription) - setStringFromMap(messagesMap, "run-started", &config.RunStarted) - setStringFromMap(messagesMap, "run-success", &config.RunSuccess) - setStringFromMap(messagesMap, "run-failure", &config.RunFailure) - setStringFromMap(messagesMap, "detection-failure", &config.DetectionFailure) - setStringFromMap(messagesMap, "pull-request-created", &config.PullRequestCreated) - setStringFromMap(messagesMap, "issue-created", &config.IssueCreated) - setStringFromMap(messagesMap, "commit-pushed", &config.CommitPushed) - setStringFromMap(messagesMap, "agent-failure-issue", &config.AgentFailureIssue) - setStringFromMap(messagesMap, "agent-failure-comment", &config.AgentFailureComment) - - return config -} - -// parseMentionsConfig parses the mentions configuration from safe-outputs frontmatter -// Mentions can be: -// - false: always escapes mentions -// - true: always allows mentions (error in strict mode) -// - object: detailed configuration with allow-team-members, allow-context, allowed, max -func parseMentionsConfig(mentions any) *MentionsConfig { - safeOutputMessagesLog.Printf("Parsing mentions configuration: type=%T", mentions) - config := &MentionsConfig{} - - // Handle boolean value - if boolVal, ok := mentions.(bool); ok { - config.Enabled = &boolVal - safeOutputMessagesLog.Printf("Mentions configured as boolean: %t", boolVal) - return config - } - - // Handle object configuration - if mentionsMap, ok := mentions.(map[string]any); ok { - // Parse allow-team-members - if allowTeamMembers, exists := mentionsMap["allow-team-members"]; exists { - if val, ok := allowTeamMembers.(bool); ok { - config.AllowTeamMembers = &val - } - } - - // Parse allow-context - if allowContext, exists := mentionsMap["allow-context"]; exists { - if val, ok := allowContext.(bool); ok { - config.AllowContext = &val - } - } - - // Parse allowed list - if allowed, exists := mentionsMap["allowed"]; exists { - if allowedArray, ok := allowed.([]any); ok { - var allowedStrings []string - for _, item := range allowedArray { - if str, ok := item.(string); ok { - // Normalize username by removing '@' prefix if present - normalized := str - if len(str) > 0 && str[0] == '@' { - normalized = str[1:] - safeOutputMessagesLog.Printf("Normalized mention '%s' to '%s'", str, normalized) - } - allowedStrings = append(allowedStrings, normalized) - } - } - config.Allowed = allowedStrings - } - } - - // Parse max - if maxVal, exists := mentionsMap["max"]; exists { - switch v := maxVal.(type) { - case int: - if v >= 1 { - config.Max = &v - } - case int64: - intVal := int(v) - if intVal >= 1 { - config.Max = &intVal - } - case uint64: - intVal := int(v) - if intVal >= 1 { - config.Max = &intVal - } - case float64: - intVal := int(v) - // Warn if truncation occurs - if v != float64(intVal) { - safeOutputsConfigLog.Printf("mentions.max: float value %.2f truncated to integer %d", v, intVal) - } - if intVal >= 1 { - config.Max = &intVal - } - } - } - } - - return config -} - -// serializeMessagesConfig converts SafeOutputMessagesConfig to JSON for passing as environment variable -func serializeMessagesConfig(messages *SafeOutputMessagesConfig) (string, error) { - if messages == nil { - return "", nil - } - safeOutputMessagesLog.Print("Serializing messages configuration to JSON") - jsonBytes, err := json.Marshal(messages) - if err != nil { - safeOutputMessagesLog.Printf("Failed to serialize messages config: %v", err) - return "", fmt.Errorf("failed to serialize messages config: %w", err) - } - safeOutputMessagesLog.Printf("Serialized messages config: %d bytes", len(jsonBytes)) - return string(jsonBytes), nil -} diff --git a/pkg/workflow/safe_outputs_messages_config.go b/pkg/workflow/safe_outputs_messages_config.go new file mode 100644 index 00000000000..f11818ea2b2 --- /dev/null +++ b/pkg/workflow/safe_outputs_messages_config.go @@ -0,0 +1,153 @@ +package workflow + +import ( + "encoding/json" + "fmt" + + "github.com/github/gh-aw/pkg/logger" +) + +var safeOutputMessagesLog = logger.New("workflow:safe_outputs_config_messages") + +// ======================================== +// Safe Output Messages Configuration +// ======================================== + +// setStringFromMap reads m[key] and assigns its string value to *dest if found. +func setStringFromMap(m map[string]any, key string, dest *string) { + if val, exists := m[key]; exists { + if str, ok := val.(string); ok { + *dest = str + } + } +} + +// parseMessagesConfig parses the messages configuration from safe-outputs frontmatter +func parseMessagesConfig(messagesMap map[string]any) *SafeOutputMessagesConfig { + safeOutputMessagesLog.Printf("Parsing messages configuration with %d fields", len(messagesMap)) + config := &SafeOutputMessagesConfig{} + + if appendOnly, exists := messagesMap["append-only-comments"]; exists { + if appendOnlyBool, ok := appendOnly.(bool); ok { + config.AppendOnlyComments = appendOnlyBool + safeOutputMessagesLog.Printf("Set append-only-comments: %t", appendOnlyBool) + } + } + + setStringFromMap(messagesMap, "footer", &config.Footer) + setStringFromMap(messagesMap, "footer-install", &config.FooterInstall) + setStringFromMap(messagesMap, "footer-workflow-recompile", &config.FooterWorkflowRecompile) + setStringFromMap(messagesMap, "footer-workflow-recompile-comment", &config.FooterWorkflowRecompileComment) + setStringFromMap(messagesMap, "staged-title", &config.StagedTitle) + setStringFromMap(messagesMap, "staged-description", &config.StagedDescription) + setStringFromMap(messagesMap, "run-started", &config.RunStarted) + setStringFromMap(messagesMap, "run-success", &config.RunSuccess) + setStringFromMap(messagesMap, "run-failure", &config.RunFailure) + setStringFromMap(messagesMap, "detection-failure", &config.DetectionFailure) + setStringFromMap(messagesMap, "pull-request-created", &config.PullRequestCreated) + setStringFromMap(messagesMap, "issue-created", &config.IssueCreated) + setStringFromMap(messagesMap, "commit-pushed", &config.CommitPushed) + setStringFromMap(messagesMap, "agent-failure-issue", &config.AgentFailureIssue) + setStringFromMap(messagesMap, "agent-failure-comment", &config.AgentFailureComment) + + return config +} + +// parseMentionsConfig parses the mentions configuration from safe-outputs frontmatter +// Mentions can be: +// - false: always escapes mentions +// - true: always allows mentions (error in strict mode) +// - object: detailed configuration with allow-team-members, allow-context, allowed, max +func parseMentionsConfig(mentions any) *MentionsConfig { + safeOutputMessagesLog.Printf("Parsing mentions configuration: type=%T", mentions) + config := &MentionsConfig{} + + // Handle boolean value + if boolVal, ok := mentions.(bool); ok { + config.Enabled = &boolVal + safeOutputMessagesLog.Printf("Mentions configured as boolean: %t", boolVal) + return config + } + + // Handle object configuration + if mentionsMap, ok := mentions.(map[string]any); ok { + // Parse allow-team-members + if allowTeamMembers, exists := mentionsMap["allow-team-members"]; exists { + if val, ok := allowTeamMembers.(bool); ok { + config.AllowTeamMembers = &val + } + } + + // Parse allow-context + if allowContext, exists := mentionsMap["allow-context"]; exists { + if val, ok := allowContext.(bool); ok { + config.AllowContext = &val + } + } + + // Parse allowed list + if allowed, exists := mentionsMap["allowed"]; exists { + if allowedArray, ok := allowed.([]any); ok { + var allowedStrings []string + for _, item := range allowedArray { + if str, ok := item.(string); ok { + // Normalize username by removing '@' prefix if present + normalized := str + if len(str) > 0 && str[0] == '@' { + normalized = str[1:] + safeOutputMessagesLog.Printf("Normalized mention '%s' to '%s'", str, normalized) + } + allowedStrings = append(allowedStrings, normalized) + } + } + config.Allowed = allowedStrings + } + } + + // Parse max + if maxVal, exists := mentionsMap["max"]; exists { + switch v := maxVal.(type) { + case int: + if v >= 1 { + config.Max = &v + } + case int64: + intVal := int(v) + if intVal >= 1 { + config.Max = &intVal + } + case uint64: + intVal := int(v) + if intVal >= 1 { + config.Max = &intVal + } + case float64: + intVal := int(v) + // Warn if truncation occurs + if v != float64(intVal) { + safeOutputsConfigLog.Printf("mentions.max: float value %.2f truncated to integer %d", v, intVal) + } + if intVal >= 1 { + config.Max = &intVal + } + } + } + } + + return config +} + +// serializeMessagesConfig converts SafeOutputMessagesConfig to JSON for passing as environment variable +func serializeMessagesConfig(messages *SafeOutputMessagesConfig) (string, error) { + if messages == nil { + return "", nil + } + safeOutputMessagesLog.Print("Serializing messages configuration to JSON") + jsonBytes, err := json.Marshal(messages) + if err != nil { + safeOutputMessagesLog.Printf("Failed to serialize messages config: %v", err) + return "", fmt.Errorf("failed to serialize messages config: %w", err) + } + safeOutputMessagesLog.Printf("Serialized messages config: %d bytes", len(jsonBytes)) + return string(jsonBytes), nil +} From 8ada134d01b5a7b3435bb600a990b78ee51699e8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Mar 2026 06:44:58 +0000 Subject: [PATCH 3/3] fix: use correct logger in safe_outputs_messages_config.go Replace safeOutputsConfigLog with safeOutputMessagesLog in parseMentionsConfig to use the logger defined in the same file Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/workflow/safe_outputs_messages_config.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/workflow/safe_outputs_messages_config.go b/pkg/workflow/safe_outputs_messages_config.go index f11818ea2b2..51513c6f154 100644 --- a/pkg/workflow/safe_outputs_messages_config.go +++ b/pkg/workflow/safe_outputs_messages_config.go @@ -125,7 +125,7 @@ func parseMentionsConfig(mentions any) *MentionsConfig { intVal := int(v) // Warn if truncation occurs if v != float64(intVal) { - safeOutputsConfigLog.Printf("mentions.max: float value %.2f truncated to integer %d", v, intVal) + safeOutputMessagesLog.Printf("mentions.max: float value %.2f truncated to integer %d", v, intVal) } if intVal >= 1 { config.Max = &intVal