From a0804835e9a483d4031f9aea3d067967bc42ef23 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Mar 2026 18:12:20 +0000 Subject: [PATCH 1/3] Initial plan From 9410c88c0b721df71c977dd5c55d448f727a503d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Mar 2026 18:29:56 +0000 Subject: [PATCH 2/3] feat: Reimplement APM artifact pack/unpack support from PR #20385 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/workflows/smoke-claude.lock.yml | 29 ++- .github/workflows/smoke-claude.md | 2 + .../src/content/docs/reference/frontmatter.md | 16 +- pkg/workflow/agentic_engine.go | 11 + pkg/workflow/apm_dependencies.go | 61 +++++- .../apm_dependencies_compilation_test.go | 118 ++++++++++- pkg/workflow/apm_dependencies_test.go | 200 ++++++++++++++---- pkg/workflow/claude_engine.go | 5 + pkg/workflow/compiler_activation_job.go | 22 ++ pkg/workflow/compiler_yaml_main_job.go | 13 +- pkg/workflow/copilot_engine.go | 5 + .../frontmatter_extraction_metadata.go | 42 +++- pkg/workflow/frontmatter_types.go | 5 +- 13 files changed, 452 insertions(+), 77 deletions(-) diff --git a/.github/workflows/smoke-claude.lock.yml b/.github/workflows/smoke-claude.lock.yml index b2da2b72e3e..d8f34f597d2 100644 --- a/.github/workflows/smoke-claude.lock.yml +++ b/.github/workflows/smoke-claude.lock.yml @@ -35,7 +35,7 @@ # # inlined-imports: true # -# gh-aw-metadata: {"schema_version":"v2","frontmatter_hash":"ce1db450f8ac78040e972a4a41fe2c780e51d97c7d931f9ec7f883a6ae8006d7","strict":true} +# gh-aw-metadata: {"schema_version":"v2","frontmatter_hash":"cf65ca18529a16d3f7278e9f39c60eee12cb0abe10843fcb1411350b7bba20b5","strict":true} name: "Smoke Claude" "on": @@ -636,6 +636,24 @@ jobs: env: GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt run: bash /opt/gh-aw/actions/print_prompt_summary.sh + - name: Install and pack APM dependencies + id: apm_pack + uses: microsoft/apm-action@5eac264e08ed8db603fe2c40983794f94cab49d8 # v1.3.1 + with: + dependencies: | + - microsoft/apm-sample-package + isolated: 'true' + pack: 'true' + archive: 'true' + target: claude + working-directory: /tmp/gh-aw/apm-workspace + - name: Upload APM bundle artifact + if: success() + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: apm + path: ${{ steps.apm_pack.outputs.bundle-path }} + retention-days: 1 - name: Upload activation artifact if: success() uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 @@ -772,6 +790,15 @@ jobs: run: bash /opt/gh-aw/actions/install_awf_binary.sh v0.23.0 - name: Install Claude Code CLI run: npm install -g @anthropic-ai/claude-code@latest + - name: Download APM bundle artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: apm + path: /tmp/gh-aw/apm-bundle + - name: Restore APM dependencies + uses: microsoft/apm-action@5eac264e08ed8db603fe2c40983794f94cab49d8 # v1.3.1 + with: + bundle: /tmp/gh-aw/apm-bundle/*.tar.gz - name: Determine automatic lockdown mode for GitHub MCP Server id: determine-automatic-lockdown uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 diff --git a/.github/workflows/smoke-claude.md b/.github/workflows/smoke-claude.md index 639c16f1e00..e2610c27a9c 100644 --- a/.github/workflows/smoke-claude.md +++ b/.github/workflows/smoke-claude.md @@ -49,6 +49,8 @@ tools: serena: languages: go: {} +dependencies: + - microsoft/apm-sample-package runtimes: go: version: "1.25" diff --git a/docs/src/content/docs/reference/frontmatter.md b/docs/src/content/docs/reference/frontmatter.md index a6f173a7e60..0a0a159189d 100644 --- a/docs/src/content/docs/reference/frontmatter.md +++ b/docs/src/content/docs/reference/frontmatter.md @@ -151,10 +151,12 @@ Each plugin repository must be specified in `org/repo` format. The compiler gene ### APM Dependencies (`dependencies:`) -Specifies [microsoft/apm](https://github.com/microsoft/apm) packages to install before workflow execution. When present, the compiler emits a step using the `microsoft/apm-action` action to install the listed packages. +Specifies [microsoft/apm](https://github.com/microsoft/apm) packages to install before workflow execution. When present, the compiler resolves and packs dependencies in the activation job, then unpacks them in the agent job for faster, deterministic startup. APM (Agent Package Manager) manages AI agent primitives such as skills, prompts, instructions, agents, and hooks. Packages can depend on other packages and APM resolves the full dependency tree. +**Simple array format** (most common): + ```yaml wrap dependencies: - microsoft/apm-sample-package @@ -162,12 +164,22 @@ dependencies: - anthropics/skills/skills/frontend-design ``` +**Object format** with options: + +```yaml wrap +dependencies: + packages: + - microsoft/apm-sample-package + - github/awesome-copilot/skills/review-and-refactor + isolated: true # clear repo primitives before unpack (default: false) +``` + Each entry is an APM package reference. Supported formats: - `owner/repo` — full APM package - `owner/repo/path/to/skill` — individual skill or primitive from a repository -The compiler generates an `Install APM dependencies` step that runs after the engine CLI installation steps. +The compiler emits a pack step in the activation job and a restore step in the agent job. The APM target is automatically inferred from the configured engine (`copilot`, `claude`, or `all` for other engines). The `isolated` flag controls whether existing `.github/` primitive directories are cleared before the bundle is unpacked in the agent job. ### Runtimes (`runtimes:`) diff --git a/pkg/workflow/agentic_engine.go b/pkg/workflow/agentic_engine.go index c29ec83ae48..f221de38b11 100644 --- a/pkg/workflow/agentic_engine.go +++ b/pkg/workflow/agentic_engine.go @@ -148,6 +148,11 @@ type WorkflowExecutor interface { // before secret redaction runs. Engines that copy session or firewall state files should // override this; the default implementation returns an empty slice. GetFirewallLogsCollectionStep(workflowData *WorkflowData) []GitHubActionStep + + // GetAPMTarget returns the APM target value to use when packing dependencies with + // microsoft/apm-action. Supported values are "copilot", "claude", and "all". + // The default implementation returns "all" (packs all primitive types). + GetAPMTarget() string } // MCPConfigProvider handles MCP (Model Context Protocol) configuration @@ -337,6 +342,12 @@ func (e *BaseEngine) GetFirewallLogsCollectionStep(workflowData *WorkflowData) [ return []GitHubActionStep{} } +// GetAPMTarget returns "all" by default (packs all primitive types). +// CopilotEngine overrides this to return "copilot"; ClaudeEngine overrides to return "claude". +func (e *BaseEngine) GetAPMTarget() string { + return "all" +} + // ParseLogMetrics provides a default no-op implementation for log parsing // Engines can override this to provide detailed log parsing and metrics extraction func (e *BaseEngine) ParseLogMetrics(logContent string, verbose bool) LogMetrics { diff --git a/pkg/workflow/apm_dependencies.go b/pkg/workflow/apm_dependencies.go index 8a794f3b07c..f4b625be7c7 100644 --- a/pkg/workflow/apm_dependencies.go +++ b/pkg/workflow/apm_dependencies.go @@ -6,30 +6,29 @@ import ( var apmDepsLog = logger.New("workflow:apm_dependencies") -// GenerateAPMDependenciesStep generates a GitHub Actions step that installs APM packages -// using the microsoft/apm-action action. The step is emitted when the workflow frontmatter -// contains a non-empty `dependencies` list in microsoft/apm format. +// 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 -// - data: WorkflowData used for action pin resolution +// - 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 GenerateAPMDependenciesStep(apmDeps *APMDependenciesInfo, data *WorkflowData) GitHubActionStep { +func GenerateAPMPackStep(apmDeps *APMDependenciesInfo, target string, data *WorkflowData) GitHubActionStep { if apmDeps == nil || len(apmDeps.Packages) == 0 { - apmDepsLog.Print("No APM dependencies to install") + apmDepsLog.Print("No APM dependencies to pack") return GitHubActionStep{} } - apmDepsLog.Printf("Generating APM dependencies step: %d packages", len(apmDeps.Packages)) + apmDepsLog.Printf("Generating APM pack step: %d packages, target=%s", len(apmDeps.Packages), target) - // Resolve the pinned action reference for microsoft/apm-action. actionRef := GetActionPin("microsoft/apm-action") - // Build step lines. The `dependencies` input uses a YAML block scalar (`|`) - // so each package is written as an indented list item on its own line. lines := []string{ - " - name: Install APM dependencies", + " - name: Install and pack APM dependencies", + " id: apm_pack", " uses: " + actionRef, " with:", " dependencies: |", @@ -39,5 +38,45 @@ func GenerateAPMDependenciesStep(apmDeps *APMDependenciesInfo, data *WorkflowDat lines = append(lines, " - "+dep) } + lines = append(lines, + " isolated: 'true'", + " pack: 'true'", + " archive: 'true'", + " target: "+target, + " working-directory: /tmp/gh-aw/apm-workspace", + ) + + return GitHubActionStep(lines) +} + +// 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 { + 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, + " 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 b39875fae97..e9f0c5d9454 100644 --- a/pkg/workflow/apm_dependencies_compilation_test.go +++ b/pkg/workflow/apm_dependencies_compilation_test.go @@ -43,14 +43,37 @@ Test with a single APM dependency lockContent := string(content) - assert.Contains(t, lockContent, "Install APM dependencies", - "Lock file should contain APM dependencies step name") + // Activation job should have the pack step + assert.Contains(t, lockContent, "Install and pack APM dependencies", + "Lock file should contain APM pack step in activation job") assert.Contains(t, lockContent, "microsoft/apm-action", "Lock file should reference the microsoft/apm-action action") - assert.Contains(t, lockContent, "dependencies: |", - "Lock file should use block scalar for dependencies input") assert.Contains(t, lockContent, "- microsoft/apm-sample-package", "Lock file should list the dependency package") + assert.Contains(t, lockContent, "id: apm_pack", + "Lock file should have apm_pack step ID") + assert.Contains(t, lockContent, "pack: 'true'", + "Lock file should include pack input") + assert.Contains(t, lockContent, "target: copilot", + "Lock file should include target inferred from copilot engine") + + // Separate APM artifact upload in activation job + assert.Contains(t, lockContent, "Upload APM bundle artifact", + "Lock file should upload APM bundle as separate artifact") + assert.Contains(t, lockContent, "name: apm", + "Lock file should name the APM artifact 'apm'") + + // Agent job should have download + restore steps + assert.Contains(t, lockContent, "Download APM bundle artifact", + "Lock file should download APM bundle in agent job") + assert.Contains(t, lockContent, "Restore APM dependencies", + "Lock file should contain APM restore step in agent job") + assert.Contains(t, lockContent, "bundle: /tmp/gh-aw/apm-bundle/*.tar.gz", + "Lock file should restore from bundle path") + + // Old install step should NOT appear + assert.NotContains(t, lockContent, "Install APM dependencies", + "Lock file should not contain the old install step name") } func TestAPMDependenciesCompilationMultiplePackages(t *testing.T) { @@ -85,8 +108,8 @@ Test with multiple APM dependencies lockContent := string(content) - assert.Contains(t, lockContent, "Install APM dependencies", - "Lock file should contain APM dependencies step name") + assert.Contains(t, lockContent, "Install and pack APM dependencies", + "Lock file should contain APM pack step") assert.Contains(t, lockContent, "microsoft/apm-action", "Lock file should reference the microsoft/apm-action action") assert.Contains(t, lockContent, "- microsoft/apm-sample-package", @@ -95,6 +118,8 @@ Test with multiple APM dependencies "Lock file should include second dependency") assert.Contains(t, lockContent, "- anthropics/skills/skills/frontend-design", "Lock file should include third dependency") + assert.Contains(t, lockContent, "Restore APM dependencies", + "Lock file should contain APM restore step") } func TestAPMDependenciesCompilationNoDependencies(t *testing.T) { @@ -125,8 +150,85 @@ Test without APM dependencies lockContent := string(content) - assert.NotContains(t, lockContent, "Install APM dependencies", - "Lock file should not contain APM dependencies step when no dependencies specified") + assert.NotContains(t, lockContent, "Install and pack APM dependencies", + "Lock file should not contain APM pack step when no dependencies specified") + assert.NotContains(t, lockContent, "Restore APM dependencies", + "Lock file should not contain APM restore step when no dependencies specified") assert.NotContains(t, lockContent, "microsoft/apm-action", "Lock file should not reference microsoft/apm-action when no dependencies specified") } + +func TestAPMDependenciesCompilationObjectFormatIsolated(t *testing.T) { + tmpDir := testutil.TempDir(t, "apm-deps-isolated-test") + + workflow := `--- +engine: copilot +on: workflow_dispatch +permissions: + issues: read + pull-requests: read +dependencies: + packages: + - microsoft/apm-sample-package + isolated: true +--- + +Test with isolated APM dependencies +` + + testFile := filepath.Join(tmpDir, "test-apm-isolated.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) + + assert.Contains(t, lockContent, "Install and pack APM dependencies", + "Lock file should contain APM pack step") + assert.Contains(t, lockContent, "Restore APM dependencies", + "Lock file should contain APM restore step") + // Restore step should include isolated: true because frontmatter says so + assert.Contains(t, lockContent, "isolated: 'true'", + "Lock file restore step should include isolated flag") +} + +func TestAPMDependenciesCompilationClaudeEngineTarget(t *testing.T) { + tmpDir := testutil.TempDir(t, "apm-deps-claude-test") + + workflow := `--- +engine: claude +on: workflow_dispatch +permissions: + issues: read + pull-requests: read +dependencies: + - microsoft/apm-sample-package +--- + +Test with Claude engine target inference +` + + testFile := filepath.Join(tmpDir, "test-apm-claude.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) + + assert.Contains(t, lockContent, "target: claude", + "Lock file should use claude target for claude engine") +} diff --git a/pkg/workflow/apm_dependencies_test.go b/pkg/workflow/apm_dependencies_test.go index 4564bef140e..77ab336218c 100644 --- a/pkg/workflow/apm_dependencies_test.go +++ b/pkg/workflow/apm_dependencies_test.go @@ -12,9 +12,10 @@ import ( func TestExtractAPMDependenciesFromFrontmatter(t *testing.T) { tests := []struct { - name string - frontmatter map[string]any - expectedDeps []string + name string + frontmatter map[string]any + expectedDeps []string + expectedIsolated bool }{ { name: "No dependencies field", @@ -53,7 +54,7 @@ func TestExtractAPMDependenciesFromFrontmatter(t *testing.T) { expectedDeps: nil, }, { - name: "Non-array value is ignored", + name: "Non-array, non-object value is ignored", frontmatter: map[string]any{ "dependencies": "microsoft/apm-sample-package", }, @@ -66,6 +67,50 @@ func TestExtractAPMDependenciesFromFrontmatter(t *testing.T) { }, expectedDeps: []string{"microsoft/apm-sample-package", "github/awesome-copilot"}, }, + { + name: "Object format with packages only", + frontmatter: map[string]any{ + "dependencies": map[string]any{ + "packages": []any{ + "microsoft/apm-sample-package", + "github/awesome-copilot", + }, + }, + }, + expectedDeps: []string{"microsoft/apm-sample-package", "github/awesome-copilot"}, + expectedIsolated: false, + }, + { + name: "Object format with isolated true", + frontmatter: map[string]any{ + "dependencies": map[string]any{ + "packages": []any{"microsoft/apm-sample-package"}, + "isolated": true, + }, + }, + expectedDeps: []string{"microsoft/apm-sample-package"}, + expectedIsolated: true, + }, + { + name: "Object format with isolated false", + frontmatter: map[string]any{ + "dependencies": map[string]any{ + "packages": []any{"microsoft/apm-sample-package"}, + "isolated": false, + }, + }, + expectedDeps: []string{"microsoft/apm-sample-package"}, + expectedIsolated: false, + }, + { + name: "Object format with empty packages", + frontmatter: map[string]any{ + "dependencies": map[string]any{ + "packages": []any{}, + }, + }, + expectedDeps: nil, + }, } for _, tt := range tests { @@ -76,52 +121,88 @@ func TestExtractAPMDependenciesFromFrontmatter(t *testing.T) { } else { 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") } }) } } -func TestGenerateAPMDependenciesStep(t *testing.T) { +func TestEngineGetAPMTarget(t *testing.T) { + tests := []struct { + name string + engine CodingAgentEngine + expected string + }{ + {name: "copilot engine returns copilot", engine: NewCopilotEngine(), expected: "copilot"}, + {name: "claude engine returns claude", engine: NewClaudeEngine(), expected: "claude"}, + {name: "codex engine returns all", engine: NewCodexEngine(), expected: "all"}, + {name: "gemini engine returns all", engine: NewGeminiEngine(), expected: "all"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tt.engine.GetAPMTarget() + assert.Equal(t, tt.expected, result, "APM target should match for engine %s", tt.engine.GetID()) + }) + } +} + +func TestGenerateAPMPackStep(t *testing.T) { tests := []struct { name string apmDeps *APMDependenciesInfo + target string expectedContains []string expectedEmpty bool }{ { name: "Nil deps returns empty step", apmDeps: nil, + target: "copilot", expectedEmpty: true, }, { name: "Empty packages returns empty step", apmDeps: &APMDependenciesInfo{Packages: []string{}}, + target: "copilot", expectedEmpty: true, }, { - name: "Single dependency", + name: "Single dependency with copilot target", apmDeps: &APMDependenciesInfo{Packages: []string{"microsoft/apm-sample-package"}}, + target: "copilot", expectedContains: []string{ - "Install APM dependencies", + "Install and pack APM dependencies", + "id: apm_pack", "microsoft/apm-action", "dependencies: |", "- microsoft/apm-sample-package", + "isolated: 'true'", + "pack: 'true'", + "archive: 'true'", + "target: copilot", + "working-directory: /tmp/gh-aw/apm-workspace", }, }, { - name: "Multiple dependencies", - apmDeps: &APMDependenciesInfo{ - Packages: []string{ - "microsoft/apm-sample-package", - "github/awesome-copilot/skills/review-and-refactor", - }, - }, + name: "Multiple dependencies with claude target", + apmDeps: &APMDependenciesInfo{Packages: []string{"microsoft/apm-sample-package", "github/skills/review"}}, + target: "claude", expectedContains: []string{ - "Install APM dependencies", + "Install and pack APM dependencies", + "id: apm_pack", "microsoft/apm-action", - "dependencies: |", "- microsoft/apm-sample-package", - "- github/awesome-copilot/skills/review-and-refactor", + "- 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", }, }, } @@ -129,7 +210,7 @@ func TestGenerateAPMDependenciesStep(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { data := &WorkflowData{Name: "test-workflow"} - step := GenerateAPMDependenciesStep(tt.apmDeps, data) + step := GenerateAPMPackStep(tt.apmDeps, tt.target, data) if tt.expectedEmpty { assert.Empty(t, step, "Step should be empty for empty/nil dependencies") @@ -138,7 +219,6 @@ func TestGenerateAPMDependenciesStep(t *testing.T) { require.NotEmpty(t, step, "Step should not be empty") - // Combine all lines for easier assertion var sb strings.Builder for _, line := range step { sb.WriteString(line + "\n") @@ -152,30 +232,70 @@ func TestGenerateAPMDependenciesStep(t *testing.T) { } } -func TestAPMDependenciesStepFormat(t *testing.T) { - deps := &APMDependenciesInfo{ - Packages: []string{ - "microsoft/apm-sample-package", - "github/awesome-copilot/skills/review-and-refactor", +func TestGenerateAPMRestoreStep(t *testing.T) { + tests := []struct { + name string + apmDeps *APMDependenciesInfo + expectedContains []string + expectedNotContains []string + expectedEmpty bool + }{ + { + name: "Nil deps returns empty step", + apmDeps: nil, + expectedEmpty: true, + }, + { + name: "Empty packages returns empty step", + apmDeps: &APMDependenciesInfo{Packages: []string{}}, + expectedEmpty: true, + }, + { + name: "Non-isolated restore step", + apmDeps: &APMDependenciesInfo{Packages: []string{"microsoft/apm-sample-package"}, Isolated: false}, + expectedContains: []string{ + "Restore APM dependencies", + "microsoft/apm-action", + "bundle: /tmp/gh-aw/apm-bundle/*.tar.gz", + }, + expectedNotContains: []string{"isolated"}, + }, + { + name: "Isolated restore step", + apmDeps: &APMDependenciesInfo{Packages: []string{"microsoft/apm-sample-package"}, Isolated: true}, + expectedContains: []string{ + "Restore APM dependencies", + "microsoft/apm-action", + "bundle: /tmp/gh-aw/apm-bundle/*.tar.gz", + "isolated: 'true'", + }, }, } - data := &WorkflowData{Name: "test-workflow"} - step := GenerateAPMDependenciesStep(deps, data) - require.NotEmpty(t, step, "Step should not be empty") + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + data := &WorkflowData{Name: "test-workflow"} + step := GenerateAPMRestoreStep(tt.apmDeps, data) - // Combine all lines for easy assertion - var sb strings.Builder - for _, line := range step { - sb.WriteString(line + "\n") + if tt.expectedEmpty { + assert.Empty(t, step, "Step should be empty for empty/nil dependencies") + return + } + + require.NotEmpty(t, step, "Step should not be empty") + + var sb strings.Builder + for _, line := range step { + sb.WriteString(line + "\n") + } + combined := sb.String() + + for _, expected := range tt.expectedContains { + assert.Contains(t, combined, expected, "Step should contain: %s", expected) + } + for _, notExpected := range tt.expectedNotContains { + assert.NotContains(t, combined, notExpected, "Step should not contain: %s", notExpected) + } + }) } - combined := sb.String() - - // Verify the step has the correct structure - assert.Contains(t, combined, "- name: Install APM dependencies", "Should have correct step name") - assert.Contains(t, combined, "uses:", "Should have uses line") - assert.Contains(t, combined, "microsoft/apm-action", "Should reference microsoft/apm-action action") - assert.Contains(t, combined, "dependencies: |", "Should use YAML block scalar for dependencies") - assert.Contains(t, combined, " - microsoft/apm-sample-package", "First dep should be properly indented") - assert.Contains(t, combined, " - github/awesome-copilot/skills/review-and-refactor", "Second dep should be properly indented") } diff --git a/pkg/workflow/claude_engine.go b/pkg/workflow/claude_engine.go index 7bf9882beb0..33651bf37f5 100644 --- a/pkg/workflow/claude_engine.go +++ b/pkg/workflow/claude_engine.go @@ -40,6 +40,11 @@ func (e *ClaudeEngine) GetModelEnvVarName() string { return constants.ClaudeCLIModelEnvVar } +// GetAPMTarget returns "claude" so that apm-action packs Claude-specific primitives. +func (e *ClaudeEngine) GetAPMTarget() string { + return "claude" +} + // GetRequiredSecretNames returns the list of secrets required by the Claude engine // This includes ANTHROPIC_API_KEY and optionally MCP_GATEWAY_API_KEY func (e *ClaudeEngine) GetRequiredSecretNames(workflowData *WorkflowData) []string { diff --git a/pkg/workflow/compiler_activation_job.go b/pkg/workflow/compiler_activation_job.go index 2136d518289..a25d009c1c5 100644 --- a/pkg/workflow/compiler_activation_job.go +++ b/pkg/workflow/compiler_activation_job.go @@ -326,6 +326,28 @@ 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. + 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 { + 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") + 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, " name: apm\n") + steps = append(steps, " path: ${{ steps.apm_pack.outputs.bundle-path }}\n") + steps = append(steps, " retention-days: 1\n") + } + // Upload aw_info.json and prompt.txt as the activation artifact for the agent job to download compilerActivationJobLog.Print("Adding activation artifact upload step") steps = append(steps, " - name: Upload activation artifact\n") diff --git a/pkg/workflow/compiler_yaml_main_job.go b/pkg/workflow/compiler_yaml_main_job.go index 2a840850514..13876fbde9b 100644 --- a/pkg/workflow/compiler_yaml_main_job.go +++ b/pkg/workflow/compiler_yaml_main_job.go @@ -231,8 +231,17 @@ func (c *Compiler) generateMainJobSteps(yaml *strings.Builder, data *WorkflowDat // Add APM (Agent Package Manager) setup step if dependencies are specified if data.APMDependencies != nil && len(data.APMDependencies.Packages) > 0 { - compilerYamlLog.Printf("Adding APM setup step: %d packages", len(data.APMDependencies.Packages)) - apmStep := GenerateAPMDependenciesStep(data.APMDependencies, data) + // Download the pre-packed APM bundle from the separate "apm" artifact + compilerYamlLog.Printf("Adding APM bundle download step: %d packages", len(data.APMDependencies.Packages)) + yaml.WriteString(" - name: Download APM bundle artifact\n") + fmt.Fprintf(yaml, " uses: %s\n", GetActionPin("actions/download-artifact")) + yaml.WriteString(" with:\n") + yaml.WriteString(" name: apm\n") + 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 { yaml.WriteString(line + "\n") } diff --git a/pkg/workflow/copilot_engine.go b/pkg/workflow/copilot_engine.go index cc09bfacaf5..1331cc011f3 100644 --- a/pkg/workflow/copilot_engine.go +++ b/pkg/workflow/copilot_engine.go @@ -55,6 +55,11 @@ func (e *CopilotEngine) GetDefaultDetectionModel() string { return string(constants.DefaultCopilotDetectionModel) } +// GetAPMTarget returns "copilot" so that apm-action packs Copilot-specific primitives. +func (e *CopilotEngine) GetAPMTarget() string { + return "copilot" +} + // GetModelEnvVarName returns the native environment variable name that the Copilot CLI uses // for model selection. Setting COPILOT_MODEL is equivalent to passing --model to the CLI. func (e *CopilotEngine) GetModelEnvVarName() string { diff --git a/pkg/workflow/frontmatter_extraction_metadata.go b/pkg/workflow/frontmatter_extraction_metadata.go index 4b29b2e1520..944e263e157 100644 --- a/pkg/workflow/frontmatter_extraction_metadata.go +++ b/pkg/workflow/frontmatter_extraction_metadata.go @@ -360,8 +360,9 @@ func extractPluginsFromFrontmatter(frontmatter map[string]any) *PluginInfo { } // extractAPMDependenciesFromFrontmatter extracts APM (Agent Package Manager) dependency -// configuration from frontmatter. Supports array format only: +// configuration from frontmatter. Supports two formats: // - Array format: ["org/pkg1", "org/pkg2"] +// - Object format: {packages: ["org/pkg1", "org/pkg2"], isolated: true} // // Returns nil if no dependencies field is present or if the field contains no packages. func extractAPMDependenciesFromFrontmatter(frontmatter map[string]any) *APMDependenciesInfo { @@ -370,22 +371,41 @@ func extractAPMDependenciesFromFrontmatter(frontmatter map[string]any) *APMDepen return nil } - depsArray, ok := value.([]any) - if !ok { - return nil - } - var packages []string - for _, item := range depsArray { - if s, ok := item.(string); ok && s != "" { - packages = append(packages, s) + var isolated bool + + switch v := value.(type) { + case []any: + // Array format: dependencies: [pkg1, pkg2] + for _, item := range v { + if s, ok := item.(string); ok && s != "" { + packages = append(packages, s) + } + } + 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) + } + } + } } + if iso, ok := v["isolated"]; ok { + if isoBool, ok := iso.(bool); ok { + isolated = isoBool + } + } + default: + return nil } if len(packages) == 0 { return nil } - frontmatterMetadataLog.Printf("Extracted %d APM dependency packages from frontmatter", len(packages)) - return &APMDependenciesInfo{Packages: packages} + frontmatterMetadataLog.Printf("Extracted %d APM dependency packages from frontmatter (isolated=%v)", len(packages), isolated) + return &APMDependenciesInfo{Packages: packages, Isolated: isolated} } diff --git a/pkg/workflow/frontmatter_types.go b/pkg/workflow/frontmatter_types.go index 3cd69b3a20b..64ba420d7da 100644 --- a/pkg/workflow/frontmatter_types.go +++ b/pkg/workflow/frontmatter_types.go @@ -87,10 +87,11 @@ type PluginsConfig struct { } // APMDependenciesInfo encapsulates APM (Agent Package Manager) dependency configuration. -// Supports both simple array format (list of package slugs) and object format with -// an "apm" sub-key. When present, a microsoft/apm-action setup step is emitted. +// Supports simple array format and object format with packages and isolated fields. +// 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 } // RateLimitConfig represents rate limiting configuration for workflow triggers From c5e5433b326c39f425a60d96796afc124920a934 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 11 Mar 2026 19:40:32 +0000 Subject: [PATCH 3/3] Add changeset [skip-ci] --- .changeset/patch-package-apm-artifacts.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/patch-package-apm-artifacts.md diff --git a/.changeset/patch-package-apm-artifacts.md b/.changeset/patch-package-apm-artifacts.md new file mode 100644 index 00000000000..a6d3c0df5b5 --- /dev/null +++ b/.changeset/patch-package-apm-artifacts.md @@ -0,0 +1,5 @@ +--- +"gh-aw": patch +--- + +Package APM dependencies during activation and restore them via the `apm` artifact so agent jobs use a deterministic bundle.