Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 38 additions & 4 deletions pkg/parser/schemas/main_workflow_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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_./-]+$",
Expand All @@ -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_./-]+$",
Expand All @@ -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"],
Expand Down
113 changes: 88 additions & 25 deletions pkg/workflow/apm_dependencies.go
Original file line number Diff line number Diff line change
@@ -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'",
Expand All @@ -49,34 +110,36 @@ 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{}
}

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)
}
70 changes: 70 additions & 0 deletions pkg/workflow/apm_dependencies_compilation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Loading