diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 9bc4eeebfac..ca5f8a0d6be 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -7970,18 +7970,25 @@ ] }, "dependencies": { - "description": "APM package references to install. Supports array format (list of package slugs) or object format with packages and isolated fields.", + "description": "APM package references to install. Supports array format (list of package slugs) or object format with packages and optional isolated/github-app fields.", "examples": [ ["microsoft/apm-sample-package", "acme/custom-tools"], { "packages": ["microsoft/apm-sample-package"], "isolated": true + }, + { + "github-app": { + "app-id": "${{ vars.APP_ID }}", + "private-key": "${{ secrets.APP_PRIVATE_KEY }}" + }, + "packages": ["acme-platform-org/acme-skills/plugins/dev-tools"] } ], "oneOf": [ { "type": "array", - "description": "Simple array of APM package references.", + "description": "Simple array of APM package references (no auth required).", "items": { "type": "string", "pattern": "^[a-zA-Z0-9_-]+/[a-zA-Z0-9_./-]+$", @@ -7990,11 +7997,11 @@ }, { "type": "object", - "description": "Object format with packages and optional isolated flag.", + "description": "Object format with packages, optional isolated flag, and optional github-app for cross-org access.", "properties": { "packages": { "type": "array", - "description": "List of APM package references to install.", + "description": "List of APM package references.", "items": { "type": "string", "pattern": "^[a-zA-Z0-9_-]+/[a-zA-Z0-9_./-]+$", @@ -8004,6 +8011,33 @@ "isolated": { "type": "boolean", "description": "If true, agent restore step clears primitive dirs before unpacking." + }, + "github-app": { + "type": "object", + "description": "GitHub App configuration for generating a token with cross-org access.", + "properties": { + "app-id": { + "type": "string", + "description": "GitHub App ID or reference (e.g. '${{ vars.APP_ID }}')." + }, + "private-key": { + "type": "string", + "description": "GitHub App private key or reference (e.g. '${{ secrets.APP_PRIVATE_KEY }}')." + }, + "owner": { + "type": "string", + "description": "Optional GitHub App installation owner. Defaults to the repository owner." + }, + "repositories": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Optional list of repositories to grant access to. Use ['*'] for org-wide access." + } + }, + "required": ["app-id", "private-key"], + "additionalProperties": false } }, "required": ["packages"], diff --git a/pkg/workflow/apm_dependencies.go b/pkg/workflow/apm_dependencies.go index f4b625be7c7..0a305fd130e 100644 --- a/pkg/workflow/apm_dependencies.go +++ b/pkg/workflow/apm_dependencies.go @@ -1,43 +1,104 @@ package workflow import ( + "fmt" + + "github.com/github/gh-aw/pkg/constants" "github.com/github/gh-aw/pkg/logger" ) var apmDepsLog = logger.New("workflow:apm_dependencies") -// GenerateAPMPackStep generates the GitHub Actions step that installs APM packages and +const ( + // apmAppTokenStepID is the step ID for the GitHub App token mint step. + apmAppTokenStepID = "apm-app-token-0" +) + +// apmPackStepID returns the step ID for the APM pack step. +// The no-app case keeps the legacy "apm_pack" step ID for backward compatibility. +func apmPackStepID(deps *APMDependenciesInfo) string { + if deps.HasGitHubApp() { + return "apm_pack_0" + } + return "apm_pack" +} + +// generateAPMAppTokenMintStep generates the step that mints a short-lived GitHub App +// installation token scoped for use in the APM pack step. +func (c *Compiler) generateAPMAppTokenMintStep(app *GitHubAppConfig) []string { + owner := app.Owner + if owner == "" { + owner = "${{ github.repository_owner }}" + } + apmDepsLog.Printf("Generating APM GitHub App token mint step: owner=%s", owner) + + steps := []string{ + " - name: Generate GitHub App token for APM dependencies\n", + " id: " + apmAppTokenStepID + "\n", + fmt.Sprintf(" uses: %s\n", GetActionPin("actions/create-github-app-token")), + " with:\n", + fmt.Sprintf(" app-id: %s\n", app.AppID), + fmt.Sprintf(" private-key: %s\n", app.PrivateKey), + fmt.Sprintf(" owner: %s\n", owner), + } + + switch { + case len(app.Repositories) == 1 && app.Repositories[0] == "*": + // Org-wide access: omit repositories field + case len(app.Repositories) == 1: + steps = append(steps, fmt.Sprintf(" repositories: %s\n", app.Repositories[0])) + case len(app.Repositories) > 1: + steps = append(steps, " repositories: |-\n") + for _, repo := range app.Repositories { + steps = append(steps, fmt.Sprintf(" %s\n", repo)) + } + default: + steps = append(steps, " repositories: ${{ github.event.repository.name }}\n") + } + + steps = append(steps, " github-api-url: ${{ github.api_url }}\n") + return steps +} + +// generateAPMPackStep generates the GitHub Actions step that installs APM packages and // packs them into a bundle in the activation job. The step always uses isolated:true because // the activation job has no repo context to preserve. // -// Parameters: -// - apmDeps: APM dependency configuration extracted from frontmatter -// - target: APM target derived from the agentic engine (e.g. "copilot", "claude", "all") -// - data: WorkflowData used for action pin resolution -// -// Returns a GitHubActionStep, or an empty step if apmDeps is nil or has no packages. -func GenerateAPMPackStep(apmDeps *APMDependenciesInfo, target string, data *WorkflowData) GitHubActionStep { +// When tokenStepID is non-empty, a GITHUB_TOKEN env override is added so the pack step +// uses the freshly minted GitHub App token instead of the default workflow token. +// When a GitHub App is configured the step ID becomes "apm_pack_0" and the artifact is +// named "apm-0"; otherwise the legacy "apm_pack" / "apm" names are kept for backward compat. +func generateAPMPackStep(apmDeps *APMDependenciesInfo, target string, tokenStepID string) GitHubActionStep { if apmDeps == nil || len(apmDeps.Packages) == 0 { apmDepsLog.Print("No APM dependencies to pack") return GitHubActionStep{} } - apmDepsLog.Printf("Generating APM pack step: %d packages, target=%s", len(apmDeps.Packages), target) + // Step ID differs between the legacy (no-app) and app-auth cases. + stepID := apmPackStepID(apmDeps) - actionRef := GetActionPin("microsoft/apm-action") + apmDepsLog.Printf("Generating APM pack step: id=%s, %d packages, target=%s", stepID, len(apmDeps.Packages), target) lines := []string{ " - name: Install and pack APM dependencies", - " id: apm_pack", - " uses: " + actionRef, - " with:", - " dependencies: |", + " id: " + stepID, + " uses: " + GetActionPin("microsoft/apm-action"), + } + + if tokenStepID != "" { + lines = append(lines, + " env:", + " GITHUB_TOKEN: ${{ steps."+tokenStepID+".outputs.token }}", + ) } + lines = append(lines, + " with:", + " dependencies: |", + ) for _, dep := range apmDeps.Packages { lines = append(lines, " - "+dep) } - lines = append(lines, " isolated: 'true'", " pack: 'true'", @@ -49,15 +110,21 @@ func GenerateAPMPackStep(apmDeps *APMDependenciesInfo, target string, data *Work return GitHubActionStep(lines) } +// apmArtifactName returns the full artifact name for the APM bundle. +// Legacy (no GitHub App): "apm". With GitHub App: "apm-0". +func apmArtifactName(deps *APMDependenciesInfo, prefix string) string { + base := constants.APMArtifactName // "apm" + if deps.HasGitHubApp() { + base += "-0" + } + return prefix + base +} + // GenerateAPMRestoreStep generates the GitHub Actions step that restores APM packages // from a pre-packed bundle in the agent job. // -// Parameters: -// - apmDeps: APM dependency configuration extracted from frontmatter -// - data: WorkflowData used for action pin resolution -// // Returns a GitHubActionStep, or an empty step if apmDeps is nil or has no packages. -func GenerateAPMRestoreStep(apmDeps *APMDependenciesInfo, data *WorkflowData) GitHubActionStep { +func GenerateAPMRestoreStep(apmDeps *APMDependenciesInfo) GitHubActionStep { if apmDeps == nil || len(apmDeps.Packages) == 0 { apmDepsLog.Print("No APM dependencies to restore") return GitHubActionStep{} @@ -65,18 +132,14 @@ func GenerateAPMRestoreStep(apmDeps *APMDependenciesInfo, data *WorkflowData) Gi apmDepsLog.Printf("Generating APM restore step (isolated=%v)", apmDeps.Isolated) - actionRef := GetActionPin("microsoft/apm-action") - lines := []string{ " - name: Restore APM dependencies", - " uses: " + actionRef, + " uses: " + GetActionPin("microsoft/apm-action"), " with:", " bundle: /tmp/gh-aw/apm-bundle/*.tar.gz", } - if apmDeps.Isolated { lines = append(lines, " isolated: 'true'") } - return GitHubActionStep(lines) } diff --git a/pkg/workflow/apm_dependencies_compilation_test.go b/pkg/workflow/apm_dependencies_compilation_test.go index e9f0c5d9454..b89e43d6881 100644 --- a/pkg/workflow/apm_dependencies_compilation_test.go +++ b/pkg/workflow/apm_dependencies_compilation_test.go @@ -232,3 +232,73 @@ Test with Claude engine target inference assert.Contains(t, lockContent, "target: claude", "Lock file should use claude target for claude engine") } + +func TestAPMDependenciesCompilationDefaultGitHubApp(t *testing.T) { + tmpDir := testutil.TempDir(t, "apm-deps-github-app-test") + + workflow := `--- +engine: claude +on: workflow_dispatch +permissions: + issues: read + pull-requests: read +dependencies: + github-app: + app-id: ${{ vars.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + packages: + - acme-platform-org/acme-skills/plugins/dev-tools + - acme-platform-org/another-package +--- + +Test with default github-app for cross-org APM access +` + + testFile := filepath.Join(tmpDir, "test-apm-github-app.md") + err := os.WriteFile(testFile, []byte(workflow), 0644) + require.NoError(t, err, "Failed to write test file") + + compiler := NewCompiler() + err = compiler.CompileWorkflow(testFile) + require.NoError(t, err, "Compilation should succeed") + + lockFile := strings.Replace(testFile, ".md", ".lock.yml", 1) + content, err := os.ReadFile(lockFile) + require.NoError(t, err, "Failed to read lock file") + + lockContent := string(content) + + // Activation job: token mint step before the pack step + assert.Contains(t, lockContent, "Generate GitHub App token for APM dependencies", + "Lock file should contain APM GitHub App token mint step") + assert.Contains(t, lockContent, "id: apm-app-token-0", + "Lock file should use indexed token step ID") + assert.Contains(t, lockContent, "${{ vars.APP_ID }}", + "Lock file should reference the app ID variable") + assert.Contains(t, lockContent, "${{ secrets.APP_PRIVATE_KEY }}", + "Lock file should reference the private key secret") + + // Activation job: pack step with GITHUB_TOKEN env override + assert.Contains(t, lockContent, "id: apm_pack_0", + "Lock file should use indexed pack step ID") + assert.Contains(t, lockContent, "GITHUB_TOKEN: ${{ steps.apm-app-token-0.outputs.token }}", + "Lock file should set GITHUB_TOKEN from app token mint step") + assert.Contains(t, lockContent, "- acme-platform-org/acme-skills/plugins/dev-tools", + "Lock file should list first dependency") + assert.Contains(t, lockContent, "- acme-platform-org/another-package", + "Lock file should list second dependency") + + // Activation job: artifact upload uses indexed name + assert.Contains(t, lockContent, "name: apm-0", + "Lock file should use indexed artifact name") + + // Agent job: download step uses indexed artifact name + assert.Contains(t, lockContent, "Download APM bundle artifact", + "Lock file should download APM bundle in agent job") + + // Agent job: single restore step handles all bundles + assert.Contains(t, lockContent, "Restore APM dependencies", + "Lock file should contain APM restore step") + assert.Contains(t, lockContent, "bundle: /tmp/gh-aw/apm-bundle/*.tar.gz", + "Lock file should restore from bundle path") +} diff --git a/pkg/workflow/apm_dependencies_test.go b/pkg/workflow/apm_dependencies_test.go index 77ab336218c..f616158e7a8 100644 --- a/pkg/workflow/apm_dependencies_test.go +++ b/pkg/workflow/apm_dependencies_test.go @@ -16,6 +16,7 @@ func TestExtractAPMDependenciesFromFrontmatter(t *testing.T) { frontmatter map[string]any expectedDeps []string expectedIsolated bool + hasDefaultApp bool }{ { name: "No dependencies field", @@ -111,6 +112,54 @@ func TestExtractAPMDependenciesFromFrontmatter(t *testing.T) { }, expectedDeps: nil, }, + // github-app support + { + name: "Object format with default github-app", + frontmatter: map[string]any{ + "dependencies": map[string]any{ + "github-app": map[string]any{ + "app-id": "${{ vars.APP_ID }}", + "private-key": "${{ secrets.APP_PRIVATE_KEY }}", + }, + "packages": []any{ + "acme-platform-org/acme-skills/plugins/dev-tools", + }, + }, + }, + expectedDeps: []string{"acme-platform-org/acme-skills/plugins/dev-tools"}, + hasDefaultApp: true, + }, + { + name: "Object format with default github-app and repositories", + frontmatter: map[string]any{ + "dependencies": map[string]any{ + "github-app": map[string]any{ + "app-id": "${{ vars.APP_ID }}", + "private-key": "${{ secrets.APP_PRIVATE_KEY }}", + "repositories": []any{"*"}, + }, + "packages": []any{ + "acme-platform-org/acme-skills/plugins/dev-tools", + "acme-platform-org/another-package", + }, + }, + }, + expectedDeps: []string{"acme-platform-org/acme-skills/plugins/dev-tools", "acme-platform-org/another-package"}, + hasDefaultApp: true, + }, + { + name: "Object entry without source is skipped", + frontmatter: map[string]any{ + "dependencies": map[string]any{ + "packages": []any{ + map[string]any{ + "foo": "bar", // non-string entries are silently skipped + }, + }, + }, + }, + expectedDeps: nil, + }, } for _, tt := range tests { @@ -122,11 +171,43 @@ func TestExtractAPMDependenciesFromFrontmatter(t *testing.T) { require.NotNil(t, result, "Should return non-nil APMDependenciesInfo") assert.Equal(t, tt.expectedDeps, result.Packages, "Extracted packages should match expected") assert.Equal(t, tt.expectedIsolated, result.Isolated, "Isolated flag should match expected") + if tt.hasDefaultApp { + assert.NotNil(t, result.GitHubApp, "Default github-app should be present") + } else { + assert.Nil(t, result.GitHubApp, "Default github-app should be absent") + } } }) } } +func TestAPMDependenciesHasGitHubApp(t *testing.T) { + tests := []struct { + name string + deps *APMDependenciesInfo + expected bool + }{ + { + name: "No github-app configured", + deps: &APMDependenciesInfo{Packages: []string{"pkg1"}}, + expected: false, + }, + { + name: "Default github-app configured", + deps: &APMDependenciesInfo{ + Packages: []string{"pkg1"}, + GitHubApp: &GitHubAppConfig{AppID: "123", PrivateKey: "key"}, + }, + expected: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, tt.deps.HasGitHubApp(), "HasGitHubApp should match expected") + }) + } +} + func TestEngineGetAPMTarget(t *testing.T) { tests := []struct { name string @@ -152,6 +233,7 @@ func TestGenerateAPMPackStep(t *testing.T) { name string apmDeps *APMDependenciesInfo target string + tokenStepID string expectedContains []string expectedEmpty bool }{ @@ -168,7 +250,7 @@ func TestGenerateAPMPackStep(t *testing.T) { expectedEmpty: true, }, { - name: "Single dependency with copilot target", + name: "Single dependency with copilot target (no token)", apmDeps: &APMDependenciesInfo{Packages: []string{"microsoft/apm-sample-package"}}, target: "copilot", expectedContains: []string{ @@ -184,33 +266,35 @@ func TestGenerateAPMPackStep(t *testing.T) { "working-directory: /tmp/gh-aw/apm-workspace", }, }, + { + name: "With GitHub App token uses apm_pack_0 step ID", + apmDeps: &APMDependenciesInfo{Packages: []string{"acme-org/pkg"}, GitHubApp: &GitHubAppConfig{AppID: "123", PrivateKey: "key"}}, + target: "claude", + tokenStepID: "apm-app-token-0", + expectedContains: []string{ + "id: apm_pack_0", + "env:", + "GITHUB_TOKEN: ${{ steps.apm-app-token-0.outputs.token }}", + "- acme-org/pkg", + "target: claude", + }, + }, { name: "Multiple dependencies with claude target", apmDeps: &APMDependenciesInfo{Packages: []string{"microsoft/apm-sample-package", "github/skills/review"}}, target: "claude", expectedContains: []string{ - "Install and pack APM dependencies", "id: apm_pack", - "microsoft/apm-action", "- microsoft/apm-sample-package", "- github/skills/review", "target: claude", }, }, - { - name: "All target for non-copilot/claude engine", - apmDeps: &APMDependenciesInfo{Packages: []string{"microsoft/apm-sample-package"}}, - target: "all", - expectedContains: []string{ - "target: all", - }, - }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - data := &WorkflowData{Name: "test-workflow"} - step := GenerateAPMPackStep(tt.apmDeps, tt.target, data) + step := generateAPMPackStep(tt.apmDeps, tt.target, tt.tokenStepID) if tt.expectedEmpty { assert.Empty(t, step, "Step should be empty for empty/nil dependencies") @@ -274,8 +358,7 @@ func TestGenerateAPMRestoreStep(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - data := &WorkflowData{Name: "test-workflow"} - step := GenerateAPMRestoreStep(tt.apmDeps, data) + step := GenerateAPMRestoreStep(tt.apmDeps) if tt.expectedEmpty { assert.Empty(t, step, "Step should be empty for empty/nil dependencies") diff --git a/pkg/workflow/compiler_activation_job.go b/pkg/workflow/compiler_activation_job.go index a2b0a77f9f7..31edffb4946 100644 --- a/pkg/workflow/compiler_activation_job.go +++ b/pkg/workflow/compiler_activation_job.go @@ -406,26 +406,40 @@ func (c *Compiler) buildActivationJob(data *WorkflowData, preActivationJobCreate compilerActivationJobLog.Print("Generating prompt in activation job") c.generatePromptInActivationJob(&steps, data, preActivationJobCreated, customJobsBeforeActivation) - // Generate APM pack step if dependencies are specified. - // The pack step runs after prompt generation and uploads as a separate "apm" artifact. + // Generate APM pack steps if dependencies are specified. + // Optionally mints a GitHub App token first when a github-app is configured. if data.APMDependencies != nil && len(data.APMDependencies.Packages) > 0 { - compilerActivationJobLog.Printf("Adding APM pack step: %d packages", len(data.APMDependencies.Packages)) apmTarget := engine.GetAPMTarget() - apmPackStep := GenerateAPMPackStep(data.APMDependencies, apmTarget, data) - for _, line := range apmPackStep { + compilerActivationJobLog.Printf("Adding APM pack step: %d packages total", + len(data.APMDependencies.Packages)) + + // Mint a GitHub App token before the pack step when configured. + tokenStepID := "" + if data.APMDependencies.GitHubApp != nil { + tokenStepID = apmAppTokenStepID + compilerActivationJobLog.Print("Adding APM GitHub App token mint step") + steps = append(steps, c.generateAPMAppTokenMintStep(data.APMDependencies.GitHubApp)...) + } + + // Generate the pack step (token is applied when tokenStepID is non-empty). + for _, line := range generateAPMPackStep(data.APMDependencies, apmTarget, tokenStepID) { steps = append(steps, line+"\n") } + // Upload the packed APM bundle as a separate artifact for the agent job to download. - // The path comes from the apm_pack step output `bundle-path`, which microsoft/apm-action - // sets to the location of the packed .tar.gz archive. - compilerActivationJobLog.Print("Adding APM bundle artifact upload step") - apmArtifactName := artifactPrefixExprForActivationJob(data) + constants.APMArtifactName + packID := apmPackStepID(data.APMDependencies) + artifactBaseName := constants.APMArtifactName + if data.APMDependencies.HasGitHubApp() { + artifactBaseName += "-0" + } + artifactName := apmArtifactName(data.APMDependencies, artifactPrefixExprForActivationJob(data)) + compilerActivationJobLog.Printf("Adding APM bundle artifact upload step (artifact=%s)", artifactBaseName) steps = append(steps, " - name: Upload APM bundle artifact\n") steps = append(steps, " if: success()\n") steps = append(steps, fmt.Sprintf(" uses: %s\n", GetActionPin("actions/upload-artifact"))) steps = append(steps, " with:\n") - steps = append(steps, fmt.Sprintf(" name: %s\n", apmArtifactName)) - steps = append(steps, " path: ${{ steps.apm_pack.outputs.bundle-path }}\n") + steps = append(steps, fmt.Sprintf(" name: %s\n", artifactName)) + steps = append(steps, fmt.Sprintf(" path: ${{ steps.%s.outputs.bundle-path }}\n", packID)) steps = append(steps, " retention-days: 1\n") } diff --git a/pkg/workflow/compiler_yaml_main_job.go b/pkg/workflow/compiler_yaml_main_job.go index 48fdbe037aa..6bad8f8f290 100644 --- a/pkg/workflow/compiler_yaml_main_job.go +++ b/pkg/workflow/compiler_yaml_main_job.go @@ -237,22 +237,20 @@ func (c *Compiler) generateMainJobSteps(yaml *strings.Builder, data *WorkflowDat } } - // Add APM (Agent Package Manager) setup step if dependencies are specified + // Add APM (Agent Package Manager) setup step if dependencies are specified. if data.APMDependencies != nil && len(data.APMDependencies.Packages) > 0 { - // Download the pre-packed APM bundle from the separate "apm" artifact. - // In workflow_call context, apply the per-invocation prefix to avoid name clashes. - compilerYamlLog.Printf("Adding APM bundle download step: %d packages", len(data.APMDependencies.Packages)) - apmArtifactName := artifactPrefixExprForDownstreamJob(data) + constants.APMArtifactName - yaml.WriteString(" - name: Download APM bundle artifact\n") + compilerYamlLog.Printf("Adding APM bundle download step: %d packages total", + len(data.APMDependencies.Packages)) + + artifactName := apmArtifactName(data.APMDependencies, artifactPrefixExprForDownstreamJob(data)) + fmt.Fprintf(yaml, " - name: Download APM bundle artifact\n") fmt.Fprintf(yaml, " uses: %s\n", GetActionPin("actions/download-artifact")) yaml.WriteString(" with:\n") - fmt.Fprintf(yaml, " name: %s\n", apmArtifactName) + fmt.Fprintf(yaml, " name: %s\n", artifactName) yaml.WriteString(" path: /tmp/gh-aw/apm-bundle\n") - // Restore APM dependencies from bundle - compilerYamlLog.Printf("Adding APM restore step") - apmStep := GenerateAPMRestoreStep(data.APMDependencies, data) - for _, line := range apmStep { + compilerYamlLog.Print("Adding APM restore step") + for _, line := range GenerateAPMRestoreStep(data.APMDependencies) { yaml.WriteString(line + "\n") } } diff --git a/pkg/workflow/frontmatter_extraction_metadata.go b/pkg/workflow/frontmatter_extraction_metadata.go index 2f4ecb8aa9a..7fe697338b0 100644 --- a/pkg/workflow/frontmatter_extraction_metadata.go +++ b/pkg/workflow/frontmatter_extraction_metadata.go @@ -352,9 +352,11 @@ func extractPluginsFromFrontmatter(frontmatter map[string]any) *PluginInfo { } // extractAPMDependenciesFromFrontmatter extracts APM (Agent Package Manager) dependency -// configuration from frontmatter. Supports two formats: +// configuration from frontmatter. Supports the following formats: +// // - Array format: ["org/pkg1", "org/pkg2"] -// - Object format: {packages: ["org/pkg1", "org/pkg2"], isolated: true} +// - Object format with packages and isolated: {packages: [...], isolated: true} +// - Object format with a default github-app: {github-app: {...}, packages: [...]} // // Returns nil if no dependencies field is present or if the field contains no packages. func extractAPMDependenciesFromFrontmatter(frontmatter map[string]any) *APMDependenciesInfo { @@ -365,6 +367,7 @@ func extractAPMDependenciesFromFrontmatter(frontmatter map[string]any) *APMDepen var packages []string var isolated bool + var defaultApp *GitHubAppConfig switch v := value.(type) { case []any: @@ -375,21 +378,32 @@ func extractAPMDependenciesFromFrontmatter(frontmatter map[string]any) *APMDepen } } case map[string]any: - // Object format: dependencies: {packages: [...], isolated: true} - if pkgsAny, ok := v["packages"]; ok { - if pkgsArray, ok := pkgsAny.([]any); ok { - for _, item := range pkgsArray { - if s, ok := item.(string); ok && s != "" { - packages = append(packages, s) - } - } + // Object format: dependencies: {github-app: {...}, packages: [...], isolated: true} + + // Parse optional default github-app at the top level + if appAny, ok := v["github-app"]; ok { + if appMap, ok := appAny.(map[string]any); ok { + defaultApp = parseAppConfig(appMap) } } + + // Parse isolated flag if iso, ok := v["isolated"]; ok { if isoBool, ok := iso.(bool); ok { isolated = isoBool } } + + // Parse packages: only string entries are supported. + if pkgsAny, ok := v["packages"]; ok { + if pkgsArray, ok := pkgsAny.([]any); ok { + for _, item := range pkgsArray { + if pkg, ok := item.(string); ok && pkg != "" { + packages = append(packages, pkg) + } + } + } + } default: return nil } @@ -398,6 +412,11 @@ func extractAPMDependenciesFromFrontmatter(frontmatter map[string]any) *APMDepen return nil } - frontmatterMetadataLog.Printf("Extracted %d APM dependency packages from frontmatter (isolated=%v)", len(packages), isolated) - return &APMDependenciesInfo{Packages: packages, Isolated: isolated} + frontmatterMetadataLog.Printf("Extracted %d APM dependency packages from frontmatter (isolated=%v, hasDefaultApp=%v)", + len(packages), isolated, defaultApp != nil) + return &APMDependenciesInfo{ + Packages: packages, + Isolated: isolated, + GitHubApp: defaultApp, + } } diff --git a/pkg/workflow/frontmatter_types.go b/pkg/workflow/frontmatter_types.go index 0395514511b..771a53c6ba9 100644 --- a/pkg/workflow/frontmatter_types.go +++ b/pkg/workflow/frontmatter_types.go @@ -87,11 +87,21 @@ type PluginsConfig struct { } // APMDependenciesInfo encapsulates APM (Agent Package Manager) dependency configuration. -// Supports simple array format and object format with packages and isolated fields. +// Supports: +// - Simple array format: dependencies: [pkg1, pkg2] +// - Object format with packages and isolated: dependencies: {packages: [...], isolated: true} +// - Object format with a default github-app for cross-org access +// // When present, a pack step is emitted in the activation job and a restore step in the agent job. type APMDependenciesInfo struct { - Packages []string // APM package slugs to install (e.g., "org/package") - Isolated bool // If true, agent restore step clears primitive dirs before unpacking + Packages []string // List of APM package sources + Isolated bool // If true, agent restore step clears primitive dirs before unpacking + GitHubApp *GitHubAppConfig // Optional GitHub App for cross-org access (applied to all packages) +} + +// HasGitHubApp returns true if a GitHub App is configured. +func (a *APMDependenciesInfo) HasGitHubApp() bool { + return a.GitHubApp != nil } // RateLimitConfig represents rate limiting configuration for workflow triggers