diff --git a/.github/aw/actions-lock.json b/.github/aw/actions-lock.json index e242badd291..dc29881145e 100644 --- a/.github/aw/actions-lock.json +++ b/.github/aw/actions-lock.json @@ -58,6 +58,11 @@ "version": "v3", "sha": "f8d387b68d61c58ab83c6c016672934102569859" }, + "actions/download-artifact@v4": { + "repo": "actions/download-artifact", + "version": "v4", + "sha": "d3f86a106a0bac45b974a628896c90dbdf5c8093" + }, "actions/download-artifact@v8.0.1": { "repo": "actions/download-artifact", "version": "v8.0.1", @@ -93,6 +98,11 @@ "version": "v6.2.0", "sha": "a309ff8b426b58ec0e2a45f0f869d46889d02405" }, + "actions/upload-artifact@v4": { + "repo": "actions/upload-artifact", + "version": "v4", + "sha": "ea165f8d65b6e75b540449e92b4886f43607fa02" + }, "actions/upload-artifact@v7": { "repo": "actions/upload-artifact", "version": "v7", @@ -163,6 +173,11 @@ "version": "v2.10.3", "sha": "9cd1b7bf3f36d5a3c3b17abc3545bfb5481912ea" }, + "microsoft/apm-action@v1.4.1": { + "repo": "microsoft/apm-action", + "version": "v1.4.1", + "sha": "a190b0b1a91031057144dc136acf9757a59c9e4d" + }, "oven-sh/setup-bun@v2.2.0": { "repo": "oven-sh/setup-bun", "version": "v2.2.0", @@ -177,11 +192,6 @@ "repo": "super-linter/super-linter", "version": "v8.5.0", "sha": "61abc07d755095a68f4987d1c2c3d1d64408f1f9" - }, - "microsoft/apm-action@v1.4.1": { - "repo": "microsoft/apm-action", - "version": "v1.4.1", - "sha": "a190b0b1a91031057144dc136acf9757a59c9e4d" } } } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 997a668dbaf..6d7e405c62c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -953,149 +953,6 @@ jobs: echo "✨ Live API test completed successfully" >> $GITHUB_STEP_SUMMARY fi - js-apm-unpack-integration: - name: APM Pack/Unpack Integration (Python vs JS) - runs-on: ubuntu-latest - timeout-minutes: 15 - needs: validate-yaml - permissions: - contents: read - concurrency: - group: ci-${{ github.ref }}-js-apm-unpack-integration - cancel-in-progress: true - steps: - - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - name: Set up Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 - with: - python-version: "3.12" - - - name: Install APM CLI - run: pip install --quiet apm-cli - - - name: Set up Node.js - id: setup-node - uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6 - with: - node-version: "24" - cache: npm - cache-dependency-path: actions/setup/js/package-lock.json - - - name: Install npm dependencies - run: cd actions/setup/js && npm ci - - - name: Create minimal APM test project - run: | - set -e - APM_PROJECT=/tmp/apm-test-project - mkdir -p "$APM_PROJECT" - cd "$APM_PROJECT" - - # apm.yml — required by the packer for name/version - cat > apm.yml << 'APMEOF' - name: gh-aw-test-package - version: 1.0.0 - APMEOF - - # apm.lock.yaml — two dependencies, mixed files and a directory entry - cat > apm.lock.yaml << 'APMEOF' - lockfile_version: '1' - apm_version: '0.8.5' - dependencies: - - repo_url: https://github.com/test-owner/skill-a - resolved_commit: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa - resolved_ref: main - virtual_path: null - is_local: false - deployed_files: - - .github/skills/skill-a/ - - .github/copilot-instructions.md - - repo_url: https://github.com/test-owner/skill-b - resolved_commit: bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb - resolved_ref: v2.0.0 - virtual_path: null - is_local: false - deployed_files: - - .github/skills/skill-b/skill.md - - .github/agents.md - APMEOF - - # Create files referenced by deployed_files - mkdir -p .github/skills/skill-a - printf '# Skill A\nThis is skill A content.\n' > .github/skills/skill-a/skill.md - printf 'Skill A helper notes.\n' > .github/skills/skill-a/notes.txt - printf '# Copilot Instructions\nFollow these rules.\n' > .github/copilot-instructions.md - mkdir -p .github/skills/skill-b - printf '# Skill B\nThis is skill B.\n' > .github/skills/skill-b/skill.md - printf '# Agents\nAgent configuration.\n' > .github/agents.md - - echo "✅ APM test project created at $APM_PROJECT" - find "$APM_PROJECT" -type f | sort - - - name: Pack APM bundle - run: | - set -e - cd /tmp/apm-test-project - mkdir -p /tmp/apm-bundle - apm pack --archive -o /tmp/apm-bundle - echo "" - echo "✅ Bundle created:" - ls -lh /tmp/apm-bundle/*.tar.gz - - - name: Unpack with Python (microsoft/apm reference) - run: | - set -e - mkdir -p /tmp/apm-out-python - BUNDLE=$(ls /tmp/apm-bundle/*.tar.gz) - apm unpack "$BUNDLE" -o /tmp/apm-out-python - echo "" - echo "=== Python unpack result ===" - find /tmp/apm-out-python -type f | sort - - - name: Unpack with JavaScript (apm_unpack.cjs) - env: - APM_BUNDLE_DIR: /tmp/apm-bundle - OUTPUT_DIR: /tmp/apm-out-js - run: | - set -e - mkdir -p /tmp/apm-out-js - node actions/setup/js/run_apm_unpack.cjs - echo "" - echo "=== JavaScript unpack result ===" - find /tmp/apm-out-js -type f | sort - - - name: Compare Python vs JavaScript unpack outputs - run: | - set -e - echo "## APM Unpack Integration Test" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - - echo "### Files unpacked by Python (reference)" >> $GITHUB_STEP_SUMMARY - echo '```' >> $GITHUB_STEP_SUMMARY - find /tmp/apm-out-python -type f | sort | sed "s|/tmp/apm-out-python/||" >> $GITHUB_STEP_SUMMARY - echo '```' >> $GITHUB_STEP_SUMMARY - - echo "### Files unpacked by JavaScript" >> $GITHUB_STEP_SUMMARY - echo '```' >> $GITHUB_STEP_SUMMARY - find /tmp/apm-out-js -type f | sort | sed "s|/tmp/apm-out-js/||" >> $GITHUB_STEP_SUMMARY - echo '```' >> $GITHUB_STEP_SUMMARY - - if diff -rq /tmp/apm-out-python /tmp/apm-out-js > /tmp/apm-diff.txt 2>&1; then - echo "### ✅ Outputs are identical" >> $GITHUB_STEP_SUMMARY - echo "✅ Python and JavaScript unpack results match" - else - echo "### ❌ Outputs differ" >> $GITHUB_STEP_SUMMARY - echo '```diff' >> $GITHUB_STEP_SUMMARY - diff -r /tmp/apm-out-python /tmp/apm-out-js >> $GITHUB_STEP_SUMMARY 2>&1 || true - echo '```' >> $GITHUB_STEP_SUMMARY - echo "❌ Python and JavaScript unpack results differ:" - cat /tmp/apm-diff.txt - diff -r /tmp/apm-out-python /tmp/apm-out-js || true - exit 1 - fi - bench: # Only run benchmarks on main branch for performance tracking if: github.ref == 'refs/heads/main' diff --git a/.github/workflows/shared/apm.md b/.github/workflows/shared/apm.md new file mode 100644 index 00000000000..aee02e99a58 --- /dev/null +++ b/.github/workflows/shared/apm.md @@ -0,0 +1,105 @@ +--- +# APM (Agent Package Manager) - Shared Workflow +# Install Microsoft APM packages in your agentic workflow. +# +# This shared workflow creates a dedicated "apm" job (depending on activation) that +# packs packages using microsoft/apm-action and uploads the bundle as an artifact. +# The agent job then downloads and unpacks the bundle as pre-steps. +# +# Documentation: https://github.com/microsoft/APM +# +# Usage: +# imports: +# - uses: shared/apm.md +# with: +# packages: +# - microsoft/apm-sample-package +# - github/awesome-copilot/skills/review-and-refactor + +import-schema: + packages: + type: array + items: + type: string + required: true + description: > + List of APM package references to install. + Format: owner/repo or owner/repo/path/to/skill. + Examples: microsoft/apm-sample-package, github/awesome-copilot/skills/review-and-refactor + +jobs: + apm: + runs-on: ubuntu-slim + needs: [activation] + permissions: {} + steps: + - name: Prepare APM package list + id: apm_prep + env: + AW_APM_PACKAGES: '${{ github.aw.import-inputs.packages }}' + run: | + DEPS=$(echo "$AW_APM_PACKAGES" | jq -r '.[] | "- " + .') + { + echo "deps<> "$GITHUB_OUTPUT" + - name: Pack APM packages + id: apm_pack + uses: microsoft/apm-action@v1.4.1 + env: + GITHUB_TOKEN: ${{ secrets.GH_AW_PLUGINS_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + with: + dependencies: ${{ steps.apm_prep.outputs.deps }} + isolated: 'true' + pack: 'true' + archive: 'true' + target: all + working-directory: /tmp/gh-aw/apm-workspace + - name: Upload APM bundle artifact + if: success() + uses: actions/upload-artifact@v4 + with: + name: ${{ needs.activation.outputs.artifact_prefix }}apm + path: ${{ steps.apm_pack.outputs.bundle-path }} + retention-days: '1' + +steps: + - name: Download APM bundle artifact + uses: actions/download-artifact@v4 + with: + name: ${{ needs.activation.outputs.artifact_prefix }}apm + path: /tmp/gh-aw/apm-bundle + - name: Find APM bundle path + id: apm_bundle + run: echo "path=$(ls /tmp/gh-aw/apm-bundle/*.tar.gz | head -1)" >> "$GITHUB_OUTPUT" + - name: Restore APM packages + uses: microsoft/apm-action@v1.4.1 + with: + bundle: ${{ steps.apm_bundle.outputs.path }} +--- + + diff --git a/.github/workflows/smoke-claude.lock.yml b/.github/workflows/smoke-claude.lock.yml index 7fbbcbe1fa2..101b3bfcf81 100644 --- a/.github/workflows/smoke-claude.lock.yml +++ b/.github/workflows/smoke-claude.lock.yml @@ -24,6 +24,7 @@ # # Resolved workflow manifest: # Imports: +# - shared/apm.md # - shared/gh.md # - shared/github-mcp-app.md # - shared/github-queries-mcp-script.md @@ -36,7 +37,7 @@ # # inlined-imports: true # -# gh-aw-metadata: {"schema_version":"v3","frontmatter_hash":"178385bb9fd92c458ccf54ca82234d086dbd88f72bf320320fd372b2624c5565","agent_id":"claude"} +# gh-aw-metadata: {"schema_version":"v3","frontmatter_hash":"8b4570801394464240788e4c538a220f7ae35f8abc29b9def1b603989de7841e","agent_id":"claude"} name: "Smoke Claude" "on": @@ -113,7 +114,6 @@ jobs: GH_AW_INFO_FIREWALL_ENABLED: "true" GH_AW_INFO_AWF_VERSION: "v0.25.4" GH_AW_INFO_AWMG_VERSION: "" - GH_AW_INFO_APM_VERSION: "v0.8.6" GH_AW_INFO_FIREWALL_TYPE: "squid" GH_AW_COMPILED_STRICT: "false" uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 @@ -200,9 +200,9 @@ jobs: run: | bash ${RUNNER_TEMP}/gh-aw/actions/create_prompt_first.sh { - cat << 'GH_AW_PROMPT_c07dfaff6885ae7d_EOF' + cat << 'GH_AW_PROMPT_b727304f9785a29f_EOF' - GH_AW_PROMPT_c07dfaff6885ae7d_EOF + GH_AW_PROMPT_b727304f9785a29f_EOF cat "${RUNNER_TEMP}/gh-aw/prompts/xpia.md" cat "${RUNNER_TEMP}/gh-aw/prompts/temp_folder_prompt.md" cat "${RUNNER_TEMP}/gh-aw/prompts/markdown.md" @@ -210,12 +210,12 @@ jobs: cat "${RUNNER_TEMP}/gh-aw/prompts/agentic_workflows_guide.md" cat "${RUNNER_TEMP}/gh-aw/prompts/cache_memory_prompt.md" cat "${RUNNER_TEMP}/gh-aw/prompts/safe_outputs_prompt.md" - cat << 'GH_AW_PROMPT_c07dfaff6885ae7d_EOF' + cat << 'GH_AW_PROMPT_b727304f9785a29f_EOF' Tools: add_comment(max:2), create_issue, close_pull_request, update_pull_request, create_pull_request_review_comment(max:5), submit_pull_request_review, resolve_pull_request_review_thread(max:5), add_labels, add_reviewer(max:2), push_to_pull_request_branch, missing_tool, missing_data, noop, post_slack_message - GH_AW_PROMPT_c07dfaff6885ae7d_EOF + GH_AW_PROMPT_b727304f9785a29f_EOF cat "${RUNNER_TEMP}/gh-aw/prompts/safe_outputs_push_to_pr_branch.md" - cat << 'GH_AW_PROMPT_c07dfaff6885ae7d_EOF' + cat << 'GH_AW_PROMPT_b727304f9785a29f_EOF' The following GitHub context information is available for this workflow: @@ -245,10 +245,12 @@ jobs: {{/if}} - GH_AW_PROMPT_c07dfaff6885ae7d_EOF + GH_AW_PROMPT_b727304f9785a29f_EOF cat "${RUNNER_TEMP}/gh-aw/prompts/github_mcp_tools_with_safeoutputs_prompt.md" - cat << 'GH_AW_PROMPT_c07dfaff6885ae7d_EOF' + cat << 'GH_AW_PROMPT_b727304f9785a29f_EOF' + + ## Serena Code Analysis The Serena MCP server is configured for **["go"]** analysis in this workspace: @@ -624,7 +626,7 @@ jobs: {"noop": {"message": "No action needed: [brief explanation of what was analyzed and why]"}} ``` - GH_AW_PROMPT_c07dfaff6885ae7d_EOF + GH_AW_PROMPT_b727304f9785a29f_EOF } > "$GH_AW_PROMPT" - name: Interpolate variables and render templates uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 @@ -719,7 +721,6 @@ jobs: GH_AW_ASSETS_ALLOWED_EXTS: "" GH_AW_ASSETS_BRANCH: "" GH_AW_ASSETS_MAX_SIZE_KB: 0 - GH_AW_INFO_APM_VERSION: v0.8.6 GH_AW_MCP_LOG_DIR: /tmp/gh-aw/mcp-logs/safeoutputs GH_AW_WORKFLOW_ID_SANITIZED: smokeclaude outputs: @@ -793,6 +794,19 @@ jobs: run: bash ${RUNNER_TEMP}/gh-aw/actions/configure_gh_for_ghe.sh env: GH_TOKEN: ${{ github.token }} + - name: Download APM bundle artifact + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: ${{ needs.activation.outputs.artifact_prefix }}apm + path: /tmp/gh-aw/apm-bundle + - id: apm_bundle + name: Find APM bundle path + run: echo "path=$(ls /tmp/gh-aw/apm-bundle/*.tar.gz | head -1)" >> "$GITHUB_OUTPUT" + - name: Restore APM packages + uses: microsoft/apm-action@a190b0b1a91031057144dc136acf9757a59c9e4d # v1.4.1 + with: + bundle: ${{ steps.apm_bundle.outputs.path }} + # Cache memory file share configuration from frontmatter processed below - name: Create cache-memory directory run: bash ${RUNNER_TEMP}/gh-aw/actions/create_cache_memory_dir.sh @@ -843,21 +857,6 @@ jobs: run: bash ${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh v0.25.4 - 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: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - APM_BUNDLE_DIR: /tmp/gh-aw/apm-bundle - with: - script: | - const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('${{ runner.temp }}/gh-aw/actions/apm_unpack.cjs'); - await main(); - name: Determine automatic lockdown mode for GitHub MCP Server id: determine-automatic-lockdown uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 @@ -899,12 +898,12 @@ jobs: mkdir -p ${RUNNER_TEMP}/gh-aw/safeoutputs mkdir -p /tmp/gh-aw/safeoutputs mkdir -p /tmp/gh-aw/mcp-logs/safeoutputs - cat > ${RUNNER_TEMP}/gh-aw/safeoutputs/config.json << 'GH_AW_SAFE_OUTPUTS_CONFIG_c87ac59e65ef6f98_EOF' - {"add_comment":{"hide_older_comments":true,"max":2},"add_labels":{"allowed":["smoke-claude"]},"add_reviewer":{"max":2,"target":"*"},"close_pull_request":{"max":1,"staged":true},"create_issue":{"close_older_issues":true,"close_older_key":"smoke-claude","expires":2,"group":true,"labels":["automation","testing"],"max":1},"create_pull_request_review_comment":{"max":5,"side":"RIGHT","target":"*"},"missing_data":{},"missing_tool":{},"noop":{"max":1,"report-as-issue":"true"},"post_slack_message":{"description":"Post a message to a fictitious Slack channel (smoke test only — no real Slack integration)","inputs":{"channel":{"default":"#general","description":"Slack channel name to post to","required":false,"type":"string"},"message":{"description":"Message text to post","required":false,"type":"string"}}},"push_to_pull_request_branch":{"if_no_changes":"warn","max_patch_size":1024,"protected_files":["package.json","bun.lockb","bunfig.toml","deno.json","deno.jsonc","deno.lock","global.json","NuGet.Config","Directory.Packages.props","mix.exs","mix.lock","go.mod","go.sum","stack.yaml","stack.yaml.lock","pom.xml","build.gradle","build.gradle.kts","settings.gradle","settings.gradle.kts","gradle.properties","package-lock.json","yarn.lock","pnpm-lock.yaml","npm-shrinkwrap.json","requirements.txt","Pipfile","Pipfile.lock","pyproject.toml","setup.py","setup.cfg","Gemfile","Gemfile.lock","uv.lock","CODEOWNERS"],"protected_path_prefixes":[".github/",".agents/"],"staged":true,"target":"*"},"resolve_pull_request_review_thread":{"max":5},"submit_pull_request_review":{"footer":"always","max":1},"update_pull_request":{"allow_body":true,"allow_title":true,"max":1,"target":"*"}} - GH_AW_SAFE_OUTPUTS_CONFIG_c87ac59e65ef6f98_EOF + cat > ${RUNNER_TEMP}/gh-aw/safeoutputs/config.json << 'GH_AW_SAFE_OUTPUTS_CONFIG_982f8532560c93f1_EOF' + {"add_comment":{"hide_older_comments":true,"max":2},"add_labels":{"allowed":["smoke-claude"]},"add_reviewer":{"max":2,"target":"*"},"close_pull_request":{"max":1,"staged":true},"create_issue":{"close_older_issues":true,"close_older_key":"smoke-claude","expires":2,"group":true,"labels":["automation","testing"],"max":1},"create_pull_request_review_comment":{"max":5,"side":"RIGHT","target":"*"},"missing_data":{},"missing_tool":{},"noop":{"max":1,"report-as-issue":"true"},"post_slack_message":{"description":"Post a message to a fictitious Slack channel (smoke test only — no real Slack integration)","inputs":{"channel":{"default":"#general","description":"Slack channel name to post to","required":false,"type":"string"},"message":{"description":"Message text to post","required":false,"type":"string"}}},"push_to_pull_request_branch":{"allowed_files":[".github/smoke-claude-push-test.md"],"if_no_changes":"warn","max_patch_size":1024,"protected_files":["package.json","bun.lockb","bunfig.toml","deno.json","deno.jsonc","deno.lock","global.json","NuGet.Config","Directory.Packages.props","mix.exs","mix.lock","go.mod","go.sum","stack.yaml","stack.yaml.lock","pom.xml","build.gradle","build.gradle.kts","settings.gradle","settings.gradle.kts","gradle.properties","package-lock.json","yarn.lock","pnpm-lock.yaml","npm-shrinkwrap.json","requirements.txt","Pipfile","Pipfile.lock","pyproject.toml","setup.py","setup.cfg","Gemfile","Gemfile.lock","uv.lock","CODEOWNERS"],"protected_path_prefixes":[".github/",".agents/"],"staged":true,"target":"*"},"resolve_pull_request_review_thread":{"max":5},"submit_pull_request_review":{"footer":"always","max":1},"update_pull_request":{"allow_body":true,"allow_title":true,"max":1,"target":"*"}} + GH_AW_SAFE_OUTPUTS_CONFIG_982f8532560c93f1_EOF - name: Write Safe Outputs Tools run: | - cat > ${RUNNER_TEMP}/gh-aw/safeoutputs/tools_meta.json << 'GH_AW_SAFE_OUTPUTS_TOOLS_META_9ba3a71275938944_EOF' + cat > ${RUNNER_TEMP}/gh-aw/safeoutputs/tools_meta.json << 'GH_AW_SAFE_OUTPUTS_TOOLS_META_5968965ac36dd63e_EOF' { "description_suffixes": { "add_comment": " CONSTRAINTS: Maximum 2 comment(s) can be added.", @@ -941,8 +940,8 @@ jobs: } ] } - GH_AW_SAFE_OUTPUTS_TOOLS_META_9ba3a71275938944_EOF - cat > ${RUNNER_TEMP}/gh-aw/safeoutputs/validation.json << 'GH_AW_SAFE_OUTPUTS_VALIDATION_61d055edbb3d996d_EOF' + GH_AW_SAFE_OUTPUTS_TOOLS_META_5968965ac36dd63e_EOF + cat > ${RUNNER_TEMP}/gh-aw/safeoutputs/validation.json << 'GH_AW_SAFE_OUTPUTS_VALIDATION_26f66c1d235bd7aa_EOF' { "add_comment": { "defaultMax": 1, @@ -1227,7 +1226,7 @@ jobs: "customValidation": "requiresOneOf:title,body" } } - GH_AW_SAFE_OUTPUTS_VALIDATION_61d055edbb3d996d_EOF + GH_AW_SAFE_OUTPUTS_VALIDATION_26f66c1d235bd7aa_EOF node ${RUNNER_TEMP}/gh-aw/actions/generate_safe_outputs_tools.cjs - name: Generate Safe Outputs MCP Server Config id: safe-outputs-config @@ -1270,7 +1269,7 @@ jobs: - name: Setup MCP Scripts Config run: | mkdir -p ${RUNNER_TEMP}/gh-aw/mcp-scripts/logs - cat > ${RUNNER_TEMP}/gh-aw/mcp-scripts/tools.json << 'GH_AW_MCP_SCRIPTS_TOOLS_c3787ebb14678b4c_EOF' + cat > ${RUNNER_TEMP}/gh-aw/mcp-scripts/tools.json << 'GH_AW_MCP_SCRIPTS_TOOLS_1c659e76e3e02313_EOF' { "serverName": "mcpscripts", "version": "1.0.0", @@ -1422,8 +1421,8 @@ jobs: } ] } - GH_AW_MCP_SCRIPTS_TOOLS_c3787ebb14678b4c_EOF - cat > ${RUNNER_TEMP}/gh-aw/mcp-scripts/mcp-server.cjs << 'GH_AW_MCP_SCRIPTS_SERVER_01c6810b899c3258_EOF' + GH_AW_MCP_SCRIPTS_TOOLS_1c659e76e3e02313_EOF + cat > ${RUNNER_TEMP}/gh-aw/mcp-scripts/mcp-server.cjs << 'GH_AW_MCP_SCRIPTS_SERVER_eb49b7785c8b0fc8_EOF' const path = require("path"); const { startHttpServer } = require("./mcp_scripts_mcp_server_http.cjs"); const configPath = path.join(__dirname, "tools.json"); @@ -1437,12 +1436,12 @@ jobs: console.error("Failed to start mcp-scripts HTTP server:", error); process.exit(1); }); - GH_AW_MCP_SCRIPTS_SERVER_01c6810b899c3258_EOF + GH_AW_MCP_SCRIPTS_SERVER_eb49b7785c8b0fc8_EOF chmod +x ${RUNNER_TEMP}/gh-aw/mcp-scripts/mcp-server.cjs - name: Setup MCP Scripts Tool Files run: | - cat > ${RUNNER_TEMP}/gh-aw/mcp-scripts/gh.sh << 'GH_AW_MCP_SCRIPTS_SH_GH_49f4c7e16a8cc483_EOF' + cat > ${RUNNER_TEMP}/gh-aw/mcp-scripts/gh.sh << 'GH_AW_MCP_SCRIPTS_SH_GH_765ed1799a8562a4_EOF' #!/bin/bash # Auto-generated mcp-script tool: gh # Execute any gh CLI command. This tool is accessible as 'mcpscripts-gh'. Provide the full command after 'gh' (e.g., args: 'pr list --limit 5'). The tool will run: gh . Use single quotes ' for complex args to avoid shell interpretation issues. @@ -1453,9 +1452,9 @@ jobs: echo " token: ${GH_AW_GH_TOKEN:0:6}..." GH_TOKEN="$GH_AW_GH_TOKEN" gh $INPUT_ARGS - GH_AW_MCP_SCRIPTS_SH_GH_49f4c7e16a8cc483_EOF + GH_AW_MCP_SCRIPTS_SH_GH_765ed1799a8562a4_EOF chmod +x ${RUNNER_TEMP}/gh-aw/mcp-scripts/gh.sh - cat > ${RUNNER_TEMP}/gh-aw/mcp-scripts/github-discussion-query.sh << 'GH_AW_MCP_SCRIPTS_SH_GITHUB-DISCUSSION-QUERY_d2da69b4ca1d33b1_EOF' + cat > ${RUNNER_TEMP}/gh-aw/mcp-scripts/github-discussion-query.sh << 'GH_AW_MCP_SCRIPTS_SH_GITHUB-DISCUSSION-QUERY_ed0e1045bd79da4c_EOF' #!/bin/bash # Auto-generated mcp-script tool: github-discussion-query # Query GitHub discussions with jq filtering support. Without --jq, returns schema and data size info. Use --jq '.' to get all data, or specific jq expressions to filter. @@ -1590,9 +1589,9 @@ jobs: EOF fi - GH_AW_MCP_SCRIPTS_SH_GITHUB-DISCUSSION-QUERY_d2da69b4ca1d33b1_EOF + GH_AW_MCP_SCRIPTS_SH_GITHUB-DISCUSSION-QUERY_ed0e1045bd79da4c_EOF chmod +x ${RUNNER_TEMP}/gh-aw/mcp-scripts/github-discussion-query.sh - cat > ${RUNNER_TEMP}/gh-aw/mcp-scripts/github-issue-query.sh << 'GH_AW_MCP_SCRIPTS_SH_GITHUB-ISSUE-QUERY_a833a71fcefc4852_EOF' + cat > ${RUNNER_TEMP}/gh-aw/mcp-scripts/github-issue-query.sh << 'GH_AW_MCP_SCRIPTS_SH_GITHUB-ISSUE-QUERY_98e0363d863bd74f_EOF' #!/bin/bash # Auto-generated mcp-script tool: github-issue-query # Query GitHub issues with jq filtering support. Without --jq, returns schema and data size info. Use --jq '.' to get all data, or specific jq expressions to filter. @@ -1671,9 +1670,9 @@ jobs: fi - GH_AW_MCP_SCRIPTS_SH_GITHUB-ISSUE-QUERY_a833a71fcefc4852_EOF + GH_AW_MCP_SCRIPTS_SH_GITHUB-ISSUE-QUERY_98e0363d863bd74f_EOF chmod +x ${RUNNER_TEMP}/gh-aw/mcp-scripts/github-issue-query.sh - cat > ${RUNNER_TEMP}/gh-aw/mcp-scripts/github-pr-query.sh << 'GH_AW_MCP_SCRIPTS_SH_GITHUB-PR-QUERY_8ef47cfa11516350_EOF' + cat > ${RUNNER_TEMP}/gh-aw/mcp-scripts/github-pr-query.sh << 'GH_AW_MCP_SCRIPTS_SH_GITHUB-PR-QUERY_7e48ca8e8ddc5d29_EOF' #!/bin/bash # Auto-generated mcp-script tool: github-pr-query # Query GitHub pull requests with jq filtering support. Without --jq, returns schema and data size info. Use --jq '.' to get all data, or specific jq expressions to filter. @@ -1758,9 +1757,9 @@ jobs: fi - GH_AW_MCP_SCRIPTS_SH_GITHUB-PR-QUERY_8ef47cfa11516350_EOF + GH_AW_MCP_SCRIPTS_SH_GITHUB-PR-QUERY_7e48ca8e8ddc5d29_EOF chmod +x ${RUNNER_TEMP}/gh-aw/mcp-scripts/github-pr-query.sh - cat > ${RUNNER_TEMP}/gh-aw/mcp-scripts/go.sh << 'GH_AW_MCP_SCRIPTS_SH_GO_603f7fe6767f80b0_EOF' + cat > ${RUNNER_TEMP}/gh-aw/mcp-scripts/go.sh << 'GH_AW_MCP_SCRIPTS_SH_GO_c5c33f025b10604b_EOF' #!/bin/bash # Auto-generated mcp-script tool: go # Execute any Go command. This tool is accessible as 'mcpscripts-go'. Provide the full command after 'go' (e.g., args: 'test ./...'). The tool will run: go . Use single quotes ' for complex args to avoid shell interpretation issues. @@ -1771,9 +1770,9 @@ jobs: go $INPUT_ARGS - GH_AW_MCP_SCRIPTS_SH_GO_603f7fe6767f80b0_EOF + GH_AW_MCP_SCRIPTS_SH_GO_c5c33f025b10604b_EOF chmod +x ${RUNNER_TEMP}/gh-aw/mcp-scripts/go.sh - cat > ${RUNNER_TEMP}/gh-aw/mcp-scripts/make.sh << 'GH_AW_MCP_SCRIPTS_SH_MAKE_6f1599655e9841fc_EOF' + cat > ${RUNNER_TEMP}/gh-aw/mcp-scripts/make.sh << 'GH_AW_MCP_SCRIPTS_SH_MAKE_e1b91b440c074c66_EOF' #!/bin/bash # Auto-generated mcp-script tool: make # Execute any Make target. This tool is accessible as 'mcpscripts-make'. Provide the target name(s) (e.g., args: 'build'). The tool will run: make . Use single quotes ' for complex args to avoid shell interpretation issues. @@ -1783,7 +1782,7 @@ jobs: echo "make $INPUT_ARGS" make $INPUT_ARGS - GH_AW_MCP_SCRIPTS_SH_MAKE_6f1599655e9841fc_EOF + GH_AW_MCP_SCRIPTS_SH_MAKE_e1b91b440c074c66_EOF chmod +x ${RUNNER_TEMP}/gh-aw/mcp-scripts/make.sh - name: Generate MCP Scripts Server Config @@ -1856,7 +1855,7 @@ jobs: export GH_AW_ENGINE="claude" export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network host -v /var/run/docker.sock:/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_GUARD_MIN_INTEGRITY -e GITHUB_MCP_GUARD_REPOS -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -e GH_AW_MCP_SCRIPTS_PORT -e GH_AW_MCP_SCRIPTS_API_KEY -e GH_AW_SAFE_OUTPUTS_PORT -e GH_AW_SAFE_OUTPUTS_API_KEY -e GH_AW_GH_TOKEN -e GH_DEBUG -e GH_TOKEN -e TAVILY_API_KEY -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw ghcr.io/github/gh-aw-mcpg:v0.2.9' - cat << GH_AW_MCP_CONFIG_2cf59e4e01b55b55_EOF | bash ${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.sh + cat << GH_AW_MCP_CONFIG_5b332f9ee71a220d_EOF | bash ${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.sh { "mcpServers": { "agenticworkflows": { @@ -1995,7 +1994,7 @@ jobs: "payloadDir": "${MCP_GATEWAY_PAYLOAD_DIR}" } } - GH_AW_MCP_CONFIG_2cf59e4e01b55b55_EOF + GH_AW_MCP_CONFIG_5b332f9ee71a220d_EOF - name: Download activation artifact uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: @@ -2287,31 +2286,49 @@ jobs: apm: needs: activation runs-on: ubuntu-slim - permissions: {} - env: - GH_AW_INFO_APM_VERSION: v0.8.6 + permissions: + {} + steps: - - name: Install and pack APM dependencies + - name: Configure GH_HOST for enterprise compatibility + id: ghes-host-config + shell: bash + run: | + # Derive GH_HOST from GITHUB_SERVER_URL so the gh CLI targets the correct + # GitHub instance (GHES/GHEC). On github.com this is a harmless no-op. + GH_HOST="${GITHUB_SERVER_URL#https://}" + GH_HOST="${GH_HOST#http://}" + echo "GH_HOST=${GH_HOST}" >> "$GITHUB_ENV" + - name: Prepare APM package list + id: apm_prep + run: | + DEPS=$(echo "$AW_APM_PACKAGES" | jq -r '.[] | "- " + .') + { + echo "deps<> "$GITHUB_OUTPUT" + env: + AW_APM_PACKAGES: "[\"microsoft/apm-sample-package\"]" + - name: Pack APM packages id: apm_pack uses: microsoft/apm-action@a190b0b1a91031057144dc136acf9757a59c9e4d # v1.4.1 env: GITHUB_TOKEN: ${{ secrets.GH_AW_PLUGINS_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} with: - dependencies: | - - microsoft/apm-sample-package - isolated: 'true' - pack: 'true' - archive: 'true' - target: claude + archive: "true" + dependencies: ${{ steps.apm_prep.outputs.deps }} + isolated: "true" + pack: "true" + target: all working-directory: /tmp/gh-aw/apm-workspace - apm-version: ${{ env.GH_AW_INFO_APM_VERSION }} - name: Upload APM bundle artifact if: success() - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: - name: apm + name: ${{ needs.activation.outputs.artifact_prefix }}apm path: ${{ steps.apm_pack.outputs.bundle-path }} - retention-days: 1 + retention-days: "1" conclusion: needs: @@ -2719,7 +2736,7 @@ jobs: echo "GH_HOST=${GH_HOST}" >> "$GITHUB_ENV" - name: Setup Safe Outputs Custom Scripts run: | - cat > ${RUNNER_TEMP}/gh-aw/actions/safe_output_script_post_slack_message.cjs << 'GH_AW_SAFE_OUTPUT_SCRIPT_POST_SLACK_MESSAGE_4b0ee5748c29dfb1_EOF' + cat > ${RUNNER_TEMP}/gh-aw/actions/safe_output_script_post_slack_message.cjs << 'GH_AW_SAFE_OUTPUT_SCRIPT_POST_SLACK_MESSAGE_9e49b541f5e2b0bc_EOF' // @ts-check /// // Auto-generated safe-output script handler: post-slack-message @@ -2739,7 +2756,7 @@ jobs: } module.exports = { main }; - GH_AW_SAFE_OUTPUT_SCRIPT_POST_SLACK_MESSAGE_4b0ee5748c29dfb1_EOF + GH_AW_SAFE_OUTPUT_SCRIPT_POST_SLACK_MESSAGE_9e49b541f5e2b0bc_EOF - name: Process Safe Outputs id: process_safe_outputs uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 @@ -2749,7 +2766,7 @@ jobs: GITHUB_SERVER_URL: ${{ github.server_url }} GITHUB_API_URL: ${{ github.api_url }} GH_AW_SAFE_OUTPUT_SCRIPTS: "{\"post_slack_message\":\"safe_output_script_post_slack_message.cjs\"}" - GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"add_comment\":{\"hide_older_comments\":true,\"max\":2},\"add_labels\":{\"allowed\":[\"smoke-claude\"]},\"add_reviewer\":{\"max\":2,\"target\":\"*\"},\"close_pull_request\":{\"max\":1,\"staged\":true},\"create_issue\":{\"close_older_issues\":true,\"close_older_key\":\"smoke-claude\",\"expires\":2,\"group\":true,\"labels\":[\"automation\",\"testing\"],\"max\":1},\"create_pull_request_review_comment\":{\"max\":5,\"side\":\"RIGHT\",\"target\":\"*\"},\"missing_data\":{},\"missing_tool\":{},\"noop\":{\"max\":1,\"report-as-issue\":\"true\"},\"push_to_pull_request_branch\":{\"if_no_changes\":\"warn\",\"max_patch_size\":1024,\"protected_files\":[\"package.json\",\"bun.lockb\",\"bunfig.toml\",\"deno.json\",\"deno.jsonc\",\"deno.lock\",\"global.json\",\"NuGet.Config\",\"Directory.Packages.props\",\"mix.exs\",\"mix.lock\",\"go.mod\",\"go.sum\",\"stack.yaml\",\"stack.yaml.lock\",\"pom.xml\",\"build.gradle\",\"build.gradle.kts\",\"settings.gradle\",\"settings.gradle.kts\",\"gradle.properties\",\"package-lock.json\",\"yarn.lock\",\"pnpm-lock.yaml\",\"npm-shrinkwrap.json\",\"requirements.txt\",\"Pipfile\",\"Pipfile.lock\",\"pyproject.toml\",\"setup.py\",\"setup.cfg\",\"Gemfile\",\"Gemfile.lock\",\"uv.lock\",\"CODEOWNERS\",\"CLAUDE.md\"],\"protected_path_prefixes\":[\".github/\",\".agents/\",\".claude/\"],\"staged\":true,\"target\":\"*\"},\"resolve_pull_request_review_thread\":{\"max\":5},\"submit_pull_request_review\":{\"footer\":\"always\",\"max\":1},\"update_pull_request\":{\"allow_body\":true,\"allow_title\":true,\"max\":1,\"target\":\"*\"}}" + GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"add_comment\":{\"hide_older_comments\":true,\"max\":2},\"add_labels\":{\"allowed\":[\"smoke-claude\"]},\"add_reviewer\":{\"max\":2,\"target\":\"*\"},\"close_pull_request\":{\"max\":1,\"staged\":true},\"create_issue\":{\"close_older_issues\":true,\"close_older_key\":\"smoke-claude\",\"expires\":2,\"group\":true,\"labels\":[\"automation\",\"testing\"],\"max\":1},\"create_pull_request_review_comment\":{\"max\":5,\"side\":\"RIGHT\",\"target\":\"*\"},\"missing_data\":{},\"missing_tool\":{},\"noop\":{\"max\":1,\"report-as-issue\":\"true\"},\"push_to_pull_request_branch\":{\"allowed_files\":[\".github/smoke-claude-push-test.md\"],\"if_no_changes\":\"warn\",\"max_patch_size\":1024,\"protected_files\":[\"package.json\",\"bun.lockb\",\"bunfig.toml\",\"deno.json\",\"deno.jsonc\",\"deno.lock\",\"global.json\",\"NuGet.Config\",\"Directory.Packages.props\",\"mix.exs\",\"mix.lock\",\"go.mod\",\"go.sum\",\"stack.yaml\",\"stack.yaml.lock\",\"pom.xml\",\"build.gradle\",\"build.gradle.kts\",\"settings.gradle\",\"settings.gradle.kts\",\"gradle.properties\",\"package-lock.json\",\"yarn.lock\",\"pnpm-lock.yaml\",\"npm-shrinkwrap.json\",\"requirements.txt\",\"Pipfile\",\"Pipfile.lock\",\"pyproject.toml\",\"setup.py\",\"setup.cfg\",\"Gemfile\",\"Gemfile.lock\",\"uv.lock\",\"CODEOWNERS\",\"CLAUDE.md\"],\"protected_path_prefixes\":[\".github/\",\".agents/\",\".claude/\"],\"staged\":true,\"target\":\"*\"},\"resolve_pull_request_review_thread\":{\"max\":5},\"submit_pull_request_review\":{\"footer\":\"always\",\"max\":1},\"update_pull_request\":{\"allow_body\":true,\"allow_title\":true,\"max\":1,\"target\":\"*\"}}" with: github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | diff --git a/.github/workflows/smoke-claude.md b/.github/workflows/smoke-claude.md index b69c46994f5..905891f8c05 100644 --- a/.github/workflows/smoke-claude.md +++ b/.github/workflows/smoke-claude.md @@ -30,6 +30,10 @@ imports: - shared/go-make.md - shared/github-mcp-app.md - shared/mcp/serena-go.md + - uses: shared/apm.md + with: + packages: + - microsoft/apm-sample-package network: allowed: - defaults @@ -44,9 +48,6 @@ tools: edit: bash: - "*" -dependencies: - packages: - - microsoft/apm-sample-package runtimes: go: version: "1.25" @@ -84,6 +85,8 @@ safe-outputs: staged: true target: "*" if-no-changes: "warn" + allowed-files: + - ".github/smoke-claude-push-test.md" add-reviewer: max: 2 target: "*" diff --git a/actions/setup/js/apm_unpack.cjs b/actions/setup/js/apm_unpack.cjs deleted file mode 100644 index 360e0f574f0..00000000000 --- a/actions/setup/js/apm_unpack.cjs +++ /dev/null @@ -1,829 +0,0 @@ -// @ts-check -/// - -/** - * APM Bundle Unpacker - * - * JavaScript implementation of the APM (Agent Package Manager) bundle unpack - * algorithm, equivalent to microsoft/apm unpacker.py. - * - * This module extracts and deploys an APM bundle (tar.gz archive) to the - * GitHub Actions workspace. It replaces the `microsoft/apm-action` restore - * step in the agent job, removing the external dependency for unpacking. - * - * Algorithm (mirrors unpacker.py): - * 1. Locate the tar.gz bundle in APM_BUNDLE_DIR - * 2. Extract to a temporary directory (with path-traversal / symlink guards) - * 3. Locate the single top-level directory inside the extracted archive - * 4. Read apm.lock.yaml from the bundle - * 5. Collect the deduplicated deployed_files list from all dependencies - * 6. Verify that every listed file actually exists in the bundle - * 7. Copy files (additive, never deletes) to OUTPUT_DIR - * 8. Clean up the temporary directory - * - * Environment variables: - * APM_BUNDLE_DIR – directory containing the *.tar.gz bundle - * (default: /tmp/gh-aw/apm-bundle) - * OUTPUT_DIR – destination directory for deployed files - * (default: GITHUB_WORKSPACE, then process.cwd()) - * - * @module apm_unpack - */ - -const fs = require("fs"); -const path = require("path"); -const os = require("os"); - -/** Lockfile filename used by current APM versions. */ -const LOCKFILE_NAME = "apm.lock.yaml"; - -// --------------------------------------------------------------------------- -// YAML parser -// --------------------------------------------------------------------------- - -/** - * Unquote a YAML scalar value produced by PyYAML's safe_dump. - * - * Handles: - * - single-quoted strings: 'value' - * - double-quoted strings: "value" - * - null / ~ literals - * - boolean literals: true / false - * - integers - * - bare strings (returned as-is) - * - * @param {string} raw - * @returns {string | number | boolean | null} - */ -function unquoteYaml(raw) { - if (raw === undefined || raw === null) return null; - const s = raw.trim(); - if (s === "" || s === "~" || s === "null") return null; - if (s === "true") return true; - if (s === "false") return false; - if (/^-?\d+$/.test(s)) return parseInt(s, 10); - if (/^-?\d+\.\d+$/.test(s)) return parseFloat(s); - // Strip surrounding quotes - if ((s.startsWith("'") && s.endsWith("'")) || (s.startsWith('"') && s.endsWith('"'))) { - return s.slice(1, -1); - } - return s; -} - -/** - * @typedef {Object} LockedDependency - * @property {string} repo_url - * @property {string | null} host - * @property {string | null} resolved_commit - * @property {string | null} resolved_ref - * @property {string | null} version - * @property {string | null} virtual_path - * @property {boolean} is_virtual - * @property {number} depth - * @property {string | null} resolved_by - * @property {string | null} package_type - * @property {string[]} deployed_files - * @property {string | null} source - * @property {string | null} local_path - * @property {string | null} content_hash - * @property {boolean} is_dev - */ - -/** - * @typedef {Object} APMLockfile - * @property {string | null} lockfile_version - * @property {string | null} generated_at - * @property {string | null} apm_version - * @property {LockedDependency[]} dependencies - * @property {Record} pack - */ - -/** - * Parse an APM lockfile (apm.lock.yaml) from a YAML string. - * - * This is a targeted parser for the specific output produced by PyYAML's - * safe_dump (default_flow_style=False, sort_keys=False). The format is: - * - * lockfile_version: '1' <- top-level scalar - * dependencies: <- top-level sequence key - * - repo_url: https://... <- first key of a mapping item - * deployed_files: <- nested sequence key (2-space indent) - * - .github/skills/foo/ <- sequence items (2-space indent) - * pack: <- top-level mapping key - * target: claude <- nested scalars (2-space indent) - * - * @param {string} content - Raw YAML string content of the lockfile. - * @returns {APMLockfile} - */ -function parseAPMLockfile(content) { - /** @type {APMLockfile} */ - const result = { - lockfile_version: null, - generated_at: null, - apm_version: null, - dependencies: [], - pack: {}, - }; - - const lines = content.split("\n"); - - // Parser states - const STATE_TOP = "top"; - const STATE_DEPS = "dependencies"; - const STATE_DEP_ITEM = "dep_item"; - const STATE_DEPLOYED_FILES = "deployed_files"; - const STATE_PACK = "pack"; - - let state = STATE_TOP; - /** @type {LockedDependency | null} */ - let currentDep = null; - - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - - // Skip blank lines and YAML comments - if (!line.trim() || line.trim().startsWith("#")) continue; - - switch (state) { - case STATE_TOP: { - if (line === "dependencies:") { - state = STATE_DEPS; - break; - } - if (line === "pack:" || line.startsWith("pack: ")) { - // pack may be a mapping block ("pack:") or an inline scalar ("pack: value") - if (line === "pack:") { - state = STATE_PACK; - } else { - const v = line.slice("pack:".length).trim(); - if (v) result.pack["_value"] = unquoteYaml(v); - } - break; - } - // Top-level scalar: key: value - const topMatch = line.match(/^([\w-]+):\s*(.*)$/); - if (topMatch) { - const k = topMatch[1]; - const v = unquoteYaml(topMatch[2]); - // @ts-ignore – dynamic key assignment on typed result - result[k] = v; - } - break; - } - - case STATE_DEPS: { - if (line.startsWith("- ")) { - // New dependency mapping item – save previous if any - if (currentDep) result.dependencies.push(currentDep); - currentDep = makeEmptyDep(); - state = STATE_DEP_ITEM; - // The first key is on the same line as "- " - const m = line.match(/^- ([\w-]+):\s*(.*)$/); - if (m) assignDepField(currentDep, m[1], unquoteYaml(m[2])); - break; - } - // Exiting dependencies section (non-indented, non-list line) - if (!line.startsWith(" ")) { - if (currentDep) { - result.dependencies.push(currentDep); - currentDep = null; - } - state = STATE_TOP; - i--; // re-process this line - } - break; - } - - case STATE_DEP_ITEM: { - if (line.startsWith("- ")) { - // Start of the next dependency item - if (currentDep) result.dependencies.push(currentDep); - currentDep = makeEmptyDep(); - const m = line.match(/^- ([\w-]+):\s*(.*)$/); - if (m) assignDepField(currentDep, m[1], unquoteYaml(m[2])); - break; - } - // 2-space indented key inside the mapping - const depKeyMatch = line.match(/^ ([\w-]+):\s*(.*)$/); - if (depKeyMatch) { - const k = depKeyMatch[1]; - if (k === "deployed_files") { - state = STATE_DEPLOYED_FILES; - } else { - if (currentDep) assignDepField(currentDep, k, unquoteYaml(depKeyMatch[2])); - } - break; - } - // Exiting dependencies section - if (!line.startsWith(" ")) { - if (currentDep) { - result.dependencies.push(currentDep); - currentDep = null; - } - state = STATE_TOP; - i--; - } - break; - } - - case STATE_DEPLOYED_FILES: { - // deployed_files list items are at 2-space indent: " - path" - const fileMatch = line.match(/^ - (.+)$/); - if (fileMatch) { - if (currentDep) currentDep.deployed_files.push(String(unquoteYaml(String(fileMatch[1].trim())))); - break; - } - // Any other 2-space key: back to dep_item - if (line.match(/^ [\w-]+:/)) { - state = STATE_DEP_ITEM; - i--; // re-process - break; - } - // New dependency item - if (line.startsWith("- ")) { - if (currentDep) result.dependencies.push(currentDep); - currentDep = makeEmptyDep(); - state = STATE_DEP_ITEM; - const m = line.match(/^- ([\w-]+):\s*(.*)$/); - if (m) assignDepField(currentDep, m[1], unquoteYaml(m[2])); - break; - } - // Exiting dependencies - if (!line.startsWith(" ")) { - if (currentDep) { - result.dependencies.push(currentDep); - currentDep = null; - } - state = STATE_TOP; - i--; - } - break; - } - - case STATE_PACK: { - const packKeyMatch = line.match(/^ ([\w-]+):\s*(.*)$/); - if (packKeyMatch) { - result.pack[packKeyMatch[1]] = unquoteYaml(packKeyMatch[2]); - break; - } - // Exiting pack mapping - if (!line.startsWith(" ")) { - state = STATE_TOP; - i--; - } - break; - } - } - } - - // Flush the last dependency - if (currentDep) result.dependencies.push(currentDep); - - return result; -} - -/** - * @returns {LockedDependency} - */ -function makeEmptyDep() { - return { - repo_url: "", - host: null, - resolved_commit: null, - resolved_ref: null, - version: null, - virtual_path: null, - is_virtual: false, - depth: 1, - resolved_by: null, - package_type: null, - deployed_files: [], - source: null, - local_path: null, - content_hash: null, - is_dev: false, - }; -} - -/** - * Assign a parsed YAML field to a LockedDependency object. - * @param {LockedDependency} dep - * @param {string} key - * @param {string | number | boolean | null} value - */ -function assignDepField(dep, key, value) { - switch (key) { - case "repo_url": - dep.repo_url = String(value ?? ""); - break; - case "host": - dep.host = value !== null ? String(value) : null; - break; - case "resolved_commit": - dep.resolved_commit = value !== null ? String(value) : null; - break; - case "resolved_ref": - dep.resolved_ref = value !== null ? String(value) : null; - break; - case "version": - dep.version = value !== null ? String(value) : null; - break; - case "virtual_path": - dep.virtual_path = value !== null ? String(value) : null; - break; - case "is_virtual": - dep.is_virtual = value === true || value === "true"; - break; - case "depth": - dep.depth = typeof value === "number" ? value : parseInt(String(value ?? "1"), 10); - break; - case "resolved_by": - dep.resolved_by = value !== null ? String(value) : null; - break; - case "package_type": - dep.package_type = value !== null ? String(value) : null; - break; - case "source": - dep.source = value !== null ? String(value) : null; - break; - case "local_path": - dep.local_path = value !== null ? String(value) : null; - break; - case "content_hash": - dep.content_hash = value !== null ? String(value) : null; - break; - case "is_dev": - dep.is_dev = value === true || value === "true"; - break; - default: - // Unknown field – ignore silently - break; - } -} - -// --------------------------------------------------------------------------- -// Bundle location helpers -// --------------------------------------------------------------------------- - -/** - * Find the first *.tar.gz file in the given directory. - * - * @param {string} bundleDir - Directory that contains the bundle archive. - * @returns {string} Absolute path to the tar.gz file. - * @throws {Error} If no bundle file is found. - */ -function findBundleFile(bundleDir) { - core.info(`[APM Unpack] Scanning bundle directory: ${bundleDir}`); - - if (!fs.existsSync(bundleDir)) { - throw new Error(`APM bundle directory not found: ${bundleDir}`); - } - - const entries = fs.readdirSync(bundleDir); - core.info(`[APM Unpack] Found ${entries.length} entries in bundle directory: ${entries.join(", ")}`); - - const tarGzFiles = entries.filter(e => e.endsWith(".tar.gz")); - if (tarGzFiles.length === 0) { - throw new Error(`No *.tar.gz bundle found in ${bundleDir}. ` + `Contents: ${entries.length === 0 ? "(empty)" : entries.join(", ")}`); - } - if (tarGzFiles.length > 1) { - core.warning(`[APM Unpack] Multiple bundles found in ${bundleDir}: ${tarGzFiles.join(", ")}. ` + `Using the first one: ${tarGzFiles[0]}`); - } - - const bundlePath = path.join(bundleDir, tarGzFiles[0]); - core.info(`[APM Unpack] Selected bundle: ${bundlePath}`); - return bundlePath; -} - -/** - * After extracting the tar.gz, locate the inner content directory. - * - * The APM packer creates archives with a single top-level directory - * (e.g. "my-package-1.2.3/") that wraps all bundle contents. - * If no such single directory exists, the extraction root is returned. - * - * @param {string} extractedDir - Root of the extracted archive. - * @returns {string} Path to the source directory containing apm.lock.yaml. - */ -function findSourceDir(extractedDir) { - const entries = fs.readdirSync(extractedDir, { withFileTypes: true }); - const dirs = entries.filter(e => e.isDirectory() && !e.isSymbolicLink()); - - if (dirs.length === 1 && entries.length === 1) { - // Single top-level directory: this is the bundle root - const sourceDir = path.join(extractedDir, dirs[0].name); - core.info(`[APM Unpack] Bundle root directory: ${sourceDir}`); - return sourceDir; - } - - // Multiple entries or no subdirectory: use extractedDir itself - core.info(`[APM Unpack] No single top-level directory found (${entries.length} entries). ` + `Using extracted root: ${extractedDir}`); - return extractedDir; -} - -/** - * Locate the lockfile inside the source directory. - * - * @param {string} sourceDir - * @returns {string} Absolute path to the lockfile. - * @throws {Error} If the lockfile is not found. - */ -function findLockfile(sourceDir) { - const primary = path.join(sourceDir, LOCKFILE_NAME); - if (fs.existsSync(primary)) { - core.info(`[APM Unpack] Found lockfile: ${primary}`); - return primary; - } - // List source dir for debugging - const entries = fs.readdirSync(sourceDir).join(", "); - throw new Error(`${LOCKFILE_NAME} not found in bundle. ` + `Source directory (${sourceDir}) contains: ${entries || "(empty)"}`); -} - -// --------------------------------------------------------------------------- -// File collection and verification -// --------------------------------------------------------------------------- - -/** - * Walk all dependencies in the lockfile and return a deduplicated, ordered list - * of deployed_files paths together with a per-dependency map. - * - * Mirrors the Python unpacker's collection loop: - * for dep in lockfile.get_all_dependencies(): - * for f in dep.deployed_files: - * ...unique_files.append(f) - * - * @param {APMLockfile} lockfile - * @returns {{ uniqueFiles: string[], depFileMap: Record }} - */ -function collectDeployedFiles(lockfile) { - /** @type {Set} */ - const seen = new Set(); - /** @type {string[]} */ - const uniqueFiles = []; - /** @type {Record} */ - const depFileMap = {}; - - for (const dep of lockfile.dependencies) { - const depKey = dep.is_virtual && dep.virtual_path ? `${dep.repo_url}/${dep.virtual_path}` : dep.source === "local" && dep.local_path ? dep.local_path : dep.repo_url; - - /** @type {string[]} */ - const depFiles = []; - for (const f of dep.deployed_files) { - depFiles.push(f); - if (!seen.has(f)) { - seen.add(f); - uniqueFiles.push(f); - } - } - if (depFiles.length > 0) { - depFileMap[depKey] = depFiles; - } - } - - return { uniqueFiles, depFileMap }; -} - -/** - * Verify that every file listed in deployed_files actually exists in the bundle. - * - * @param {string} sourceDir - Extracted bundle directory. - * @param {string[]} uniqueFiles - Deduplicated list of relative file paths. - * @throws {Error} If any listed file is missing from the bundle. - */ -function verifyBundleContents(sourceDir, uniqueFiles) { - const missing = uniqueFiles.filter(f => { - const candidate = path.join(sourceDir, f); - return !fs.existsSync(candidate); - }); - - if (missing.length > 0) { - throw new Error(`Bundle verification failed – the following deployed files are missing from the bundle:\n` + missing.map(m => ` - ${m}`).join("\n")); - } - core.info(`[APM Unpack] Bundle verification passed (${uniqueFiles.length} file(s) verified)`); -} - -// --------------------------------------------------------------------------- -// Security helpers -// --------------------------------------------------------------------------- - -/** - * Validate that a relative path from the lockfile is safe to deploy. - * Rejects absolute paths and path-traversal attempts (mirrors unpacker.py). - * - * @param {string} relPath - Relative path string from deployed_files. - * @throws {Error} If the path is unsafe. - */ -function assertSafePath(relPath) { - if (path.isAbsolute(relPath) || relPath.startsWith("/")) { - throw new Error(`Refusing to unpack unsafe absolute path from bundle lockfile: ${JSON.stringify(relPath)}`); - } - const parts = relPath.split(/[\\/]/); - if (parts.includes("..")) { - throw new Error(`Refusing to unpack path-traversal entry from bundle lockfile: ${JSON.stringify(relPath)}`); - } -} - -/** - * Verify that the resolved destination path stays within outputDirResolved. - * - * @param {string} destPath - Absolute destination path. - * @param {string} outputDirResolved - Resolved absolute output directory. - * @throws {Error} If the dest escapes the output directory. - */ -function assertDestInsideOutput(destPath, outputDirResolved) { - const resolved = path.resolve(destPath); - if (!resolved.startsWith(outputDirResolved + path.sep) && resolved !== outputDirResolved) { - throw new Error(`Refusing to unpack path that escapes the output directory: ${JSON.stringify(destPath)}`); - } -} - -// --------------------------------------------------------------------------- -// Copy helpers -// --------------------------------------------------------------------------- - -/** - * Recursively copy a directory tree from src to dest, skipping symbolic links. - * Parent directories are created automatically. - * - * @param {string} src - Source directory. - * @param {string} dest - Destination directory. - * @returns {number} Number of files copied. - */ -function copyDirRecursive(src, dest) { - let count = 0; - const entries = fs.readdirSync(src, { withFileTypes: true }); - for (const entry of entries) { - const srcPath = path.join(src, entry.name); - const destPath = path.join(dest, entry.name); - if (entry.isSymbolicLink()) { - // Security: skip symlinks (mirrors unpacker.py's ignore_symlinks) - core.warning(`[APM Unpack] Skipping symlink: ${srcPath}`); - continue; - } - if (entry.isDirectory()) { - fs.mkdirSync(destPath, { recursive: true }); - count += copyDirRecursive(srcPath, destPath); - } else if (entry.isFile()) { - fs.mkdirSync(path.dirname(destPath), { recursive: true }); - fs.copyFileSync(srcPath, destPath); - count++; - } - } - return count; -} - -// --------------------------------------------------------------------------- -// Main unpack function -// --------------------------------------------------------------------------- - -/** - * @typedef {Object} UnpackResult - * @property {string} bundlePath - Path to the original bundle archive. - * @property {string[]} files - Unique list of deployed file paths. - * @property {boolean} verified - Whether bundle completeness was verified. - * @property {Record} dependencyFiles - Files per dependency key. - * @property {number} skippedCount - Files skipped (symlinks, missing). - * @property {Record} packMeta - Pack metadata from lockfile. - */ - -/** - * Extract and apply an APM bundle to an output directory. - * - * This is the core implementation that mirrors the Python unpack_bundle() - * function in unpacker.py. All extraction and copying is done with the same - * additive-only, symlink-skipping, path-traversal-checking semantics. - * - * @param {object} params - * @param {string} params.bundleDir - Directory containing the *.tar.gz bundle. - * @param {string} params.outputDir - Target directory to copy files into. - * @param {boolean} [params.skipVerify] - Skip completeness verification. - * @param {boolean} [params.dryRun] - Resolve file list but write nothing. - * @returns {Promise} - */ -async function unpackBundle({ bundleDir, outputDir, skipVerify = false, dryRun = false }) { - core.info("=== APM Bundle Unpack ==="); - core.info(`[APM Unpack] Bundle directory : ${bundleDir}`); - core.info(`[APM Unpack] Output directory : ${outputDir}`); - core.info(`[APM Unpack] Skip verify : ${skipVerify}`); - core.info(`[APM Unpack] Dry run : ${dryRun}`); - - // 1. Find the archive - const bundlePath = findBundleFile(bundleDir); - - // 2. Extract to temporary directory - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "apm-unpack-")); - core.info(`[APM Unpack] Temp directory : ${tempDir}`); - - let sourceDir; - try { - core.info(`[APM Unpack] Extracting archive: ${bundlePath}`); - await exec.exec("tar", ["-xzf", bundlePath, "-C", tempDir]); - core.info(`[APM Unpack] Extraction complete`); - - // 3. Find the inner bundle directory - sourceDir = findSourceDir(tempDir); - - // List bundle contents for debugging - const allBundleFiles = listDirRecursive(sourceDir); - core.info(`[APM Unpack] Bundle contains ${allBundleFiles.length} file(s):`); - allBundleFiles.slice(0, 50).forEach(f => core.info(` ${f}`)); - if (allBundleFiles.length > 50) { - core.info(` ... and ${allBundleFiles.length - 50} more`); - } - - // 4. Read lockfile - const lockfilePath = findLockfile(sourceDir); - const lockfileContent = fs.readFileSync(lockfilePath, "utf-8"); - core.info(`[APM Unpack] Lockfile size: ${lockfileContent.length} bytes`); - - // 5. Parse lockfile - const lockfile = parseAPMLockfile(lockfileContent); - core.info(`[APM Unpack] Lockfile version : ${lockfile.lockfile_version}`); - core.info(`[APM Unpack] APM version : ${lockfile.apm_version}`); - core.info(`[APM Unpack] Dependencies : ${lockfile.dependencies.length}`); - - if (lockfile.pack && Object.keys(lockfile.pack).length > 0) { - core.info(`[APM Unpack] Pack metadata : ${JSON.stringify(lockfile.pack)}`); - } - - for (const dep of lockfile.dependencies) { - core.info(`[APM Unpack] dep: ${dep.repo_url}` + (dep.resolved_ref ? `@${dep.resolved_ref}` : "") + (dep.resolved_commit ? ` (${dep.resolved_commit.slice(0, 8)})` : "") + ` – ${dep.deployed_files.length} file(s)`); - dep.deployed_files.forEach(f => core.info(` → ${f}`)); - } - - // 6. Collect deployed files (deduplicated) - const { uniqueFiles, depFileMap } = collectDeployedFiles(lockfile); - core.info(`[APM Unpack] Total deployed files (deduplicated): ${uniqueFiles.length}`); - - // 7. Verify bundle completeness - if (!skipVerify) { - verifyBundleContents(sourceDir, uniqueFiles); - } else { - core.info("[APM Unpack] Skipping bundle verification (skipVerify=true)"); - } - - const verified = !skipVerify; - - // 8. Dry-run early exit - if (dryRun) { - core.info("[APM Unpack] Dry-run mode: resolved file list without writing"); - return { - bundlePath, - files: uniqueFiles, - verified, - dependencyFiles: depFileMap, - skippedCount: 0, - packMeta: lockfile.pack, - }; - } - - // 9. Copy files to output directory (additive only, never deletes) - const outputDirResolved = path.resolve(outputDir); - fs.mkdirSync(outputDirResolved, { recursive: true }); - - let skipped = 0; - let copied = 0; - - for (const relPath of uniqueFiles) { - // Guard: reject unsafe paths from the lockfile - assertSafePath(relPath); - - const dest = path.join(outputDirResolved, relPath); - assertDestInsideOutput(dest, outputDirResolved); - - // Strip trailing slash for path operations (directories end with /) - const relPathClean = relPath.endsWith("/") ? relPath.slice(0, -1) : relPath; - const src = path.join(sourceDir, relPathClean); - - if (!fs.existsSync(src)) { - core.warning(`[APM Unpack] Skipping missing entry: ${relPath}`); - skipped++; - continue; - } - - // Security: skip symlinks - const srcLstat = fs.lstatSync(src); - if (srcLstat.isSymbolicLink()) { - core.warning(`[APM Unpack] Skipping symlink: ${relPath}`); - skipped++; - continue; - } - - if (srcLstat.isDirectory() || relPath.endsWith("/")) { - core.info(`[APM Unpack] Copying directory: ${relPath}`); - const destDir = path.join(outputDirResolved, relPathClean); - fs.mkdirSync(destDir, { recursive: true }); - const n = copyDirRecursive(src, destDir); - core.info(`[APM Unpack] → Copied ${n} file(s) from ${relPath}`); - copied += n; - } else { - core.info(`[APM Unpack] Copying file: ${relPath}`); - fs.mkdirSync(path.dirname(dest), { recursive: true }); - fs.copyFileSync(src, dest); - copied++; - } - } - - core.info(`[APM Unpack] Done: ${copied} file(s) copied, ${skipped} skipped`); - core.info(`[APM Unpack] Deployed to: ${outputDirResolved}`); - - // Log what was deployed for easy verification - const deployedFiles = listDirRecursive(outputDirResolved); - core.info(`[APM Unpack] Output directory now contains ${deployedFiles.length} file(s) (top-level snapshot):`); - deployedFiles.slice(0, 30).forEach(f => core.info(` ${f}`)); - - return { - bundlePath, - files: uniqueFiles, - verified, - dependencyFiles: depFileMap, - skippedCount: skipped, - packMeta: lockfile.pack, - }; - } finally { - // Always clean up temp directory - try { - fs.rmSync(tempDir, { recursive: true, force: true }); - core.info(`[APM Unpack] Cleaned up temp directory: ${tempDir}`); - } catch (cleanupErr) { - core.warning(`[APM Unpack] Failed to clean up temp directory ${tempDir}: ${cleanupErr}`); - } - } -} - -/** - * List all file paths recursively under dir, relative to dir. - * Symbolic links are skipped. - * - * @param {string} dir - * @returns {string[]} - */ -function listDirRecursive(dir) { - /** @type {string[]} */ - const result = []; - try { - const entries = fs.readdirSync(dir, { withFileTypes: true }); - for (const entry of entries) { - if (entry.isSymbolicLink()) continue; - const rel = entry.name; - if (entry.isDirectory()) { - const sub = listDirRecursive(path.join(dir, entry.name)); - result.push(...sub.map(s => rel + "/" + s)); - } else { - result.push(rel); - } - } - } catch { - // Best-effort listing - } - return result; -} - -// --------------------------------------------------------------------------- -// Entry point -// --------------------------------------------------------------------------- - -/** - * Main entry point called by the github-script step. - * - * Reads configuration from environment variables: - * APM_BUNDLE_DIR – directory with the bundle tar.gz (default: /tmp/gh-aw/apm-bundle) - * OUTPUT_DIR – destination for deployed files (default: GITHUB_WORKSPACE) - */ -async function main() { - const bundleDir = process.env.APM_BUNDLE_DIR || "/tmp/gh-aw/apm-bundle"; - const outputDir = process.env.OUTPUT_DIR || process.env.GITHUB_WORKSPACE || process.cwd(); - - core.info("[APM Unpack] Starting APM bundle unpacking"); - core.info(`[APM Unpack] APM_BUNDLE_DIR : ${bundleDir}`); - core.info(`[APM Unpack] OUTPUT_DIR : ${outputDir}`); - - try { - const result = await unpackBundle({ bundleDir, outputDir }); - - core.info("[APM Unpack] ✅ APM bundle unpacked successfully"); - core.info(`[APM Unpack] Files deployed : ${result.files.length}`); - core.info(`[APM Unpack] Files skipped : ${result.skippedCount}`); - core.info(`[APM Unpack] Verified : ${result.verified}`); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - core.error(`[APM Unpack] ❌ Failed to unpack APM bundle: ${msg}`); - throw err; - } -} - -module.exports = { - main, - unpackBundle, - parseAPMLockfile, - unquoteYaml, - collectDeployedFiles, - findBundleFile, - findSourceDir, - findLockfile, - verifyBundleContents, - assertSafePath, - assertDestInsideOutput, - copyDirRecursive, - listDirRecursive, -}; diff --git a/actions/setup/js/apm_unpack.test.cjs b/actions/setup/js/apm_unpack.test.cjs deleted file mode 100644 index 7ec966b8ac1..00000000000 --- a/actions/setup/js/apm_unpack.test.cjs +++ /dev/null @@ -1,980 +0,0 @@ -// @ts-check -/// - -import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; -const fs = require("fs"); -const path = require("path"); -const os = require("os"); - -// --------------------------------------------------------------------------- -// Global mock setup -// --------------------------------------------------------------------------- - -const mockCore = { - info: vi.fn(), - warning: vi.fn(), - error: vi.fn(), - setFailed: vi.fn(), - setOutput: vi.fn(), -}; - -const mockExec = { - exec: vi.fn(), -}; - -// Establish globals before requiring the module -global.core = mockCore; -global.exec = mockExec; - -const { - parseAPMLockfile, - unquoteYaml, - collectDeployedFiles, - findBundleFile, - findSourceDir, - findLockfile, - verifyBundleContents, - assertSafePath, - assertDestInsideOutput, - copyDirRecursive, - listDirRecursive, - unpackBundle, -} = require("./apm_unpack.cjs"); - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -/** Create a temp directory and return its path. */ -function makeTempDir() { - return fs.mkdtempSync(path.join(os.tmpdir(), "apm-unpack-test-")); -} - -/** Remove a directory recursively (best-effort). */ -function removeTempDir(dir) { - if (dir && fs.existsSync(dir)) { - fs.rmSync(dir, { recursive: true, force: true }); - } -} - -/** Write a file, creating parent directories as needed. */ -function writeFile(dir, relPath, content = "content") { - const full = path.join(dir, relPath); - fs.mkdirSync(path.dirname(full), { recursive: true }); - fs.writeFileSync(full, content, "utf-8"); - return full; -} - -/** - * Minimal valid apm.lock.yaml content for a single dependency. - * @param {object} [overrides] - */ -function minimalLockfile({ repoUrl = "https://github.com/owner/repo", files = [".github/skills/foo/"] } = {}) { - const fileLines = files.map(f => ` - ${f}`).join("\n"); - return `lockfile_version: '1' -generated_at: '2024-01-15T10:00:00.000000+00:00' -apm_version: 0.8.5 -dependencies: -- repo_url: ${repoUrl} - host: github.com - resolved_commit: abc123def456789 - resolved_ref: main - version: '1.0.0' - depth: 1 - package_type: generic - deployed_files: -${fileLines} -`; -} - -// --------------------------------------------------------------------------- -// unquoteYaml -// --------------------------------------------------------------------------- - -describe("unquoteYaml", () => { - it("returns null for empty/null/undefined/~ values", () => { - expect(unquoteYaml("")).toBeNull(); - expect(unquoteYaml("~")).toBeNull(); - expect(unquoteYaml("null")).toBeNull(); - expect(unquoteYaml(null)).toBeNull(); - expect(unquoteYaml(undefined)).toBeNull(); - }); - - it("parses boolean literals", () => { - expect(unquoteYaml("true")).toBe(true); - expect(unquoteYaml("false")).toBe(false); - }); - - it("parses integer literals", () => { - expect(unquoteYaml("0")).toBe(0); - expect(unquoteYaml("1")).toBe(1); - expect(unquoteYaml("42")).toBe(42); - expect(unquoteYaml("-7")).toBe(-7); - }); - - it("parses float literals", () => { - expect(unquoteYaml("3.14")).toBeCloseTo(3.14); - expect(unquoteYaml("-1.5")).toBeCloseTo(-1.5); - }); - - it("strips single quotes", () => { - expect(unquoteYaml("'hello'")).toBe("hello"); - expect(unquoteYaml("'1'")).toBe("1"); - expect(unquoteYaml("'true'")).toBe("true"); - }); - - it("strips double quotes", () => { - expect(unquoteYaml('"world"')).toBe("world"); - expect(unquoteYaml('"2024-01-01"')).toBe("2024-01-01"); - }); - - it("returns bare strings unchanged", () => { - expect(unquoteYaml("main")).toBe("main"); - expect(unquoteYaml("github.com")).toBe("github.com"); - expect(unquoteYaml("https://github.com/owner/repo")).toBe("https://github.com/owner/repo"); - }); - - it("trims surrounding whitespace before processing", () => { - expect(unquoteYaml(" 'hello' ")).toBe("hello"); - expect(unquoteYaml(" 42 ")).toBe(42); - }); -}); - -// --------------------------------------------------------------------------- -// parseAPMLockfile – basic structure -// --------------------------------------------------------------------------- - -describe("parseAPMLockfile – top-level fields", () => { - it("parses lockfile_version, generated_at, apm_version", () => { - const yaml = `lockfile_version: '1' -generated_at: '2024-01-15T10:00:00.000000+00:00' -apm_version: 0.8.5 -dependencies: -`; - const result = parseAPMLockfile(yaml); - expect(result.lockfile_version).toBe("1"); - expect(result.generated_at).toBe("2024-01-15T10:00:00.000000+00:00"); - expect(result.apm_version).toBe("0.8.5"); - expect(result.dependencies).toHaveLength(0); - }); - - it("handles missing optional fields gracefully", () => { - const yaml = `lockfile_version: '1' -dependencies: -`; - const result = parseAPMLockfile(yaml); - expect(result.lockfile_version).toBe("1"); - expect(result.apm_version).toBeNull(); - expect(result.dependencies).toHaveLength(0); - }); - - it("parses pack metadata block", () => { - const yaml = `lockfile_version: '1' -dependencies: -pack: - target: claude - format: apm - generated_at: '2024-01-15T10:00:00.000000+00:00' -`; - const result = parseAPMLockfile(yaml); - expect(result.pack.target).toBe("claude"); - expect(result.pack.format).toBe("apm"); - }); - - it("returns empty result for empty/blank input", () => { - const result = parseAPMLockfile(""); - expect(result.dependencies).toHaveLength(0); - expect(result.lockfile_version).toBeNull(); - }); - - it("ignores YAML comment lines", () => { - const yaml = `# This is a comment -lockfile_version: '1' -# Another comment -dependencies: -`; - const result = parseAPMLockfile(yaml); - expect(result.lockfile_version).toBe("1"); - }); -}); - -// --------------------------------------------------------------------------- -// parseAPMLockfile – dependency items -// --------------------------------------------------------------------------- - -describe("parseAPMLockfile – dependencies", () => { - it("parses a single dependency with deployed_files", () => { - const yaml = minimalLockfile({ - repoUrl: "https://github.com/microsoft/apm-sample-package", - files: [".github/skills/my-skill/", ".claude/skills/my-skill/"], - }); - const result = parseAPMLockfile(yaml); - - expect(result.dependencies).toHaveLength(1); - const dep = result.dependencies[0]; - expect(dep.repo_url).toBe("https://github.com/microsoft/apm-sample-package"); - expect(dep.host).toBe("github.com"); - expect(dep.resolved_commit).toBe("abc123def456789"); - expect(dep.resolved_ref).toBe("main"); - expect(dep.version).toBe("1.0.0"); - expect(dep.depth).toBe(1); - expect(dep.package_type).toBe("generic"); - expect(dep.deployed_files).toEqual([".github/skills/my-skill/", ".claude/skills/my-skill/"]); - }); - - it("parses multiple dependencies", () => { - const yaml = `lockfile_version: '1' -dependencies: -- repo_url: https://github.com/owner/pkg-a - host: github.com - resolved_commit: aaaa - resolved_ref: main - depth: 1 - deployed_files: - - .github/skills/pkg-a/ -- repo_url: https://github.com/owner/pkg-b - host: github.com - resolved_commit: bbbb - resolved_ref: v2 - depth: 1 - deployed_files: - - .github/skills/pkg-b/ - - .claude/skills/pkg-b/ -`; - const result = parseAPMLockfile(yaml); - expect(result.dependencies).toHaveLength(2); - expect(result.dependencies[0].repo_url).toBe("https://github.com/owner/pkg-a"); - expect(result.dependencies[0].deployed_files).toEqual([".github/skills/pkg-a/"]); - expect(result.dependencies[1].repo_url).toBe("https://github.com/owner/pkg-b"); - expect(result.dependencies[1].deployed_files).toEqual([".github/skills/pkg-b/", ".claude/skills/pkg-b/"]); - }); - - it("handles dependency with no deployed_files", () => { - const yaml = `lockfile_version: '1' -dependencies: -- repo_url: https://github.com/owner/empty-pkg - host: github.com - depth: 1 -`; - const result = parseAPMLockfile(yaml); - expect(result.dependencies).toHaveLength(1); - expect(result.dependencies[0].deployed_files).toEqual([]); - }); - - it("parses boolean fields: is_virtual, is_dev", () => { - const yaml = `lockfile_version: '1' -dependencies: -- repo_url: https://github.com/owner/repo - is_virtual: true - is_dev: true - depth: 1 -`; - const result = parseAPMLockfile(yaml); - const dep = result.dependencies[0]; - expect(dep.is_virtual).toBe(true); - expect(dep.is_dev).toBe(true); - }); - - it("parses virtual package with virtual_path", () => { - const yaml = `lockfile_version: '1' -dependencies: -- repo_url: https://github.com/owner/mono - virtual_path: packages/sub - is_virtual: true - depth: 2 - deployed_files: - - .github/skills/sub/ -`; - const result = parseAPMLockfile(yaml); - const dep = result.dependencies[0]; - expect(dep.virtual_path).toBe("packages/sub"); - expect(dep.is_virtual).toBe(true); - expect(dep.depth).toBe(2); - }); - - it("parses local dependency with source and local_path", () => { - const yaml = `lockfile_version: '1' -dependencies: -- repo_url: local - source: local - local_path: ./my-local-pkg - depth: 1 - deployed_files: - - .github/skills/local/ -`; - const result = parseAPMLockfile(yaml); - const dep = result.dependencies[0]; - expect(dep.source).toBe("local"); - expect(dep.local_path).toBe("./my-local-pkg"); - }); - - it("handles deployed_files with plain file paths (no trailing slash)", () => { - const yaml = `lockfile_version: '1' -dependencies: -- repo_url: https://github.com/owner/repo - deployed_files: - - .github/copilot-instructions.md - - README.md -`; - const result = parseAPMLockfile(yaml); - expect(result.dependencies[0].deployed_files).toEqual([".github/copilot-instructions.md", "README.md"]); - }); - - it("handles multiple fields appearing after deployed_files", () => { - const yaml = `lockfile_version: '1' -dependencies: -- repo_url: https://github.com/owner/repo - host: github.com - resolved_commit: abc123 - depth: 1 - deployed_files: - - .github/skills/foo/ - resolved_ref: main - package_type: generic -`; - const result = parseAPMLockfile(yaml); - const dep = result.dependencies[0]; - // After deployed_files block, parser should resume dep_item and pick up remaining keys - expect(dep.deployed_files).toEqual([".github/skills/foo/"]); - expect(dep.resolved_ref).toBe("main"); - expect(dep.package_type).toBe("generic"); - }); -}); - -// --------------------------------------------------------------------------- -// collectDeployedFiles -// --------------------------------------------------------------------------- - -describe("collectDeployedFiles", () => { - it("deduplicates files across dependencies", () => { - const lockfile = { - lockfile_version: "1", - generated_at: null, - apm_version: null, - pack: {}, - dependencies: [ - { ...makeEmptyDep(), repo_url: "a", deployed_files: ["file1.txt", "file2.txt"] }, - { ...makeEmptyDep(), repo_url: "b", deployed_files: ["file2.txt", "file3.txt"] }, - ], - }; - const { uniqueFiles, depFileMap } = collectDeployedFiles(lockfile); - expect(uniqueFiles).toEqual(["file1.txt", "file2.txt", "file3.txt"]); - expect(depFileMap["a"]).toEqual(["file1.txt", "file2.txt"]); - expect(depFileMap["b"]).toEqual(["file2.txt", "file3.txt"]); - }); - - it("preserves insertion order (mirrors Python seen set logic)", () => { - const lockfile = { - lockfile_version: "1", - generated_at: null, - apm_version: null, - pack: {}, - dependencies: [ - { ...makeEmptyDep(), repo_url: "a", deployed_files: ["z.txt", "a.txt"] }, - { ...makeEmptyDep(), repo_url: "b", deployed_files: ["m.txt"] }, - ], - }; - const { uniqueFiles } = collectDeployedFiles(lockfile); - expect(uniqueFiles).toEqual(["z.txt", "a.txt", "m.txt"]); - }); - - it("uses virtual_path in dep key for virtual packages", () => { - const lockfile = { - lockfile_version: "1", - generated_at: null, - apm_version: null, - pack: {}, - dependencies: [ - { - ...makeEmptyDep(), - repo_url: "https://github.com/owner/mono", - is_virtual: true, - virtual_path: "packages/sub", - deployed_files: ["skill/"], - }, - ], - }; - const { depFileMap } = collectDeployedFiles(lockfile); - expect(depFileMap["https://github.com/owner/mono/packages/sub"]).toEqual(["skill/"]); - }); - - it("uses local_path as key for local packages", () => { - const lockfile = { - lockfile_version: "1", - generated_at: null, - apm_version: null, - pack: {}, - dependencies: [ - { - ...makeEmptyDep(), - repo_url: "local", - source: "local", - local_path: "./my-pkg", - deployed_files: ["skill/"], - }, - ], - }; - const { depFileMap } = collectDeployedFiles(lockfile); - expect(depFileMap["./my-pkg"]).toEqual(["skill/"]); - }); - - it("omits empty deployed_files from depFileMap", () => { - const lockfile = { - lockfile_version: "1", - generated_at: null, - apm_version: null, - pack: {}, - dependencies: [{ ...makeEmptyDep(), repo_url: "a", deployed_files: [] }], - }; - const { uniqueFiles, depFileMap } = collectDeployedFiles(lockfile); - expect(uniqueFiles).toHaveLength(0); - expect(Object.keys(depFileMap)).toHaveLength(0); - }); -}); - -// Helper used in collectDeployedFiles tests -function makeEmptyDep() { - return { - repo_url: "", - host: null, - resolved_commit: null, - resolved_ref: null, - version: null, - virtual_path: null, - is_virtual: false, - depth: 1, - resolved_by: null, - package_type: null, - deployed_files: [], - source: null, - local_path: null, - content_hash: null, - is_dev: false, - }; -} - -// --------------------------------------------------------------------------- -// findBundleFile -// --------------------------------------------------------------------------- - -describe("findBundleFile", () => { - let tempDir; - - beforeEach(() => { - tempDir = makeTempDir(); - vi.clearAllMocks(); - global.core = mockCore; - }); - afterEach(() => removeTempDir(tempDir)); - - it("finds a single tar.gz file in the bundle directory", () => { - writeFile(tempDir, "my-package-1.0.0.tar.gz", "fake-archive"); - const result = findBundleFile(tempDir); - expect(result).toBe(path.join(tempDir, "my-package-1.0.0.tar.gz")); - }); - - it("throws when directory does not exist", () => { - expect(() => findBundleFile("/nonexistent/path/xyz")).toThrow(/not found/); - }); - - it("throws when no tar.gz file exists", () => { - writeFile(tempDir, "readme.txt", "not a bundle"); - expect(() => findBundleFile(tempDir)).toThrow(/No \*.tar\.gz bundle found/); - }); - - it("uses first file and warns when multiple bundles are present", () => { - writeFile(tempDir, "pkg-1.0.0.tar.gz", "archive-1"); - writeFile(tempDir, "pkg-2.0.0.tar.gz", "archive-2"); - const result = findBundleFile(tempDir); - expect(result).toMatch(/\.tar\.gz$/); - expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("Multiple bundles found")); - }); -}); - -// --------------------------------------------------------------------------- -// findSourceDir -// --------------------------------------------------------------------------- - -describe("findSourceDir", () => { - let tempDir; - - beforeEach(() => { - tempDir = makeTempDir(); - vi.clearAllMocks(); - global.core = mockCore; - }); - afterEach(() => removeTempDir(tempDir)); - - it("returns the single subdirectory when the archive has one top-level dir", () => { - const inner = path.join(tempDir, "my-package-1.0.0"); - fs.mkdirSync(inner); - const result = findSourceDir(tempDir); - expect(result).toBe(inner); - }); - - it("returns the extraction root when multiple entries exist", () => { - fs.mkdirSync(path.join(tempDir, "dir-a")); - fs.mkdirSync(path.join(tempDir, "dir-b")); - const result = findSourceDir(tempDir); - expect(result).toBe(tempDir); - }); - - it("returns the extraction root when only files exist (no subdirectory)", () => { - writeFile(tempDir, "apm.lock.yaml", "lockfile"); - const result = findSourceDir(tempDir); - expect(result).toBe(tempDir); - }); -}); - -// --------------------------------------------------------------------------- -// findLockfile -// --------------------------------------------------------------------------- - -describe("findLockfile", () => { - let tempDir; - - beforeEach(() => { - tempDir = makeTempDir(); - vi.clearAllMocks(); - global.core = mockCore; - }); - afterEach(() => removeTempDir(tempDir)); - - it("finds apm.lock.yaml", () => { - writeFile(tempDir, "apm.lock.yaml", "content"); - const result = findLockfile(tempDir); - expect(result).toBe(path.join(tempDir, "apm.lock.yaml")); - expect(mockCore.warning).not.toHaveBeenCalled(); - }); - - it("throws when apm.lock.yaml does not exist", () => { - expect(() => findLockfile(tempDir)).toThrow(/apm\.lock\.yaml not found/); - }); - - it("throws when only legacy apm.lock exists (not supported)", () => { - writeFile(tempDir, "apm.lock", "content"); - expect(() => findLockfile(tempDir)).toThrow(/apm\.lock\.yaml not found/); - }); -}); - -// --------------------------------------------------------------------------- -// verifyBundleContents -// --------------------------------------------------------------------------- - -describe("verifyBundleContents", () => { - let tempDir; - - beforeEach(() => { - tempDir = makeTempDir(); - vi.clearAllMocks(); - global.core = mockCore; - }); - afterEach(() => removeTempDir(tempDir)); - - it("passes when all files exist in the bundle", () => { - writeFile(tempDir, ".github/skills/foo/skill.md"); - writeFile(tempDir, ".claude/skills/foo/skill.md"); - expect(() => verifyBundleContents(tempDir, [".github/skills/foo/skill.md", ".claude/skills/foo/skill.md"])).not.toThrow(); - expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("verification passed")); - }); - - it("throws when a file listed in deployed_files is missing", () => { - writeFile(tempDir, ".github/skills/foo/skill.md"); - // .claude/skills/foo/skill.md is missing - expect(() => verifyBundleContents(tempDir, [".github/skills/foo/skill.md", ".claude/skills/foo/skill.md"])).toThrow(/Bundle verification failed/); - }); - - it("passes for directory entries (path ending with /)", () => { - // A directory itself counts as existing - fs.mkdirSync(path.join(tempDir, ".github", "skills", "foo"), { recursive: true }); - expect(() => verifyBundleContents(tempDir, [".github/skills/foo/"])).not.toThrow(); - }); - - it("passes for empty deployed_files list", () => { - expect(() => verifyBundleContents(tempDir, [])).not.toThrow(); - }); -}); - -// --------------------------------------------------------------------------- -// assertSafePath -// --------------------------------------------------------------------------- - -describe("assertSafePath", () => { - it("accepts valid relative paths", () => { - expect(() => assertSafePath(".github/skills/foo/skill.md")).not.toThrow(); - expect(() => assertSafePath("README.md")).not.toThrow(); - expect(() => assertSafePath("some/nested/dir/file.txt")).not.toThrow(); - }); - - it("rejects absolute paths", () => { - expect(() => assertSafePath("/etc/passwd")).toThrow(/absolute path/i); - expect(() => assertSafePath("/tmp/attack")).toThrow(/absolute path/i); - }); - - it("rejects path traversal with ..", () => { - expect(() => assertSafePath("../outside")).toThrow(/path-traversal/i); - expect(() => assertSafePath("safe/../../../etc/passwd")).toThrow(/path-traversal/i); - }); -}); - -// --------------------------------------------------------------------------- -// assertDestInsideOutput -// --------------------------------------------------------------------------- - -describe("assertDestInsideOutput", () => { - it("accepts paths inside the output directory", () => { - const output = path.resolve("/tmp/test-output"); - expect(() => assertDestInsideOutput(output + "/subdir/file.txt", output)).not.toThrow(); - expect(() => assertDestInsideOutput(output + "/nested/deep/file.txt", output)).not.toThrow(); - }); - - it("rejects paths that escape the output directory", () => { - const output = path.resolve("/tmp/test-output"); - expect(() => assertDestInsideOutput("/tmp/other/file.txt", output)).toThrow(/escapes/i); - expect(() => assertDestInsideOutput("/etc/passwd", output)).toThrow(/escapes/i); - }); -}); - -// --------------------------------------------------------------------------- -// copyDirRecursive -// --------------------------------------------------------------------------- - -describe("copyDirRecursive", () => { - let srcDir; - let destDir; - - beforeEach(() => { - srcDir = makeTempDir(); - destDir = makeTempDir(); - vi.clearAllMocks(); - global.core = mockCore; - }); - afterEach(() => { - removeTempDir(srcDir); - removeTempDir(destDir); - }); - - it("copies all files from source to destination", () => { - writeFile(srcDir, "file1.txt", "a"); - writeFile(srcDir, "subdir/file2.txt", "b"); - writeFile(srcDir, "subdir/nested/file3.txt", "c"); - - const count = copyDirRecursive(srcDir, destDir); - expect(count).toBe(3); - expect(fs.existsSync(path.join(destDir, "file1.txt"))).toBe(true); - expect(fs.existsSync(path.join(destDir, "subdir", "file2.txt"))).toBe(true); - expect(fs.existsSync(path.join(destDir, "subdir", "nested", "file3.txt"))).toBe(true); - }); - - it("preserves file content", () => { - writeFile(srcDir, "hello.txt", "Hello, World!"); - copyDirRecursive(srcDir, destDir); - const content = fs.readFileSync(path.join(destDir, "hello.txt"), "utf-8"); - expect(content).toBe("Hello, World!"); - }); - - it("skips symbolic links with a warning", () => { - writeFile(srcDir, "real.txt", "real"); - // Create a symlink (may not work on all platforms but is tested here) - try { - fs.symlinkSync(path.join(srcDir, "real.txt"), path.join(srcDir, "link.txt")); - copyDirRecursive(srcDir, destDir); - // The symlink should not be copied - expect(fs.existsSync(path.join(destDir, "link.txt"))).toBe(false); - expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("symlink")); - } catch { - // Symlink creation may fail in some environments – skip - } - }); - - it("returns 0 for an empty source directory", () => { - const count = copyDirRecursive(srcDir, destDir); - expect(count).toBe(0); - }); -}); - -// --------------------------------------------------------------------------- -// listDirRecursive -// --------------------------------------------------------------------------- - -describe("listDirRecursive", () => { - let tempDir; - - beforeEach(() => { - tempDir = makeTempDir(); - }); - afterEach(() => removeTempDir(tempDir)); - - it("lists all files recursively", () => { - writeFile(tempDir, "a.txt"); - writeFile(tempDir, "sub/b.txt"); - writeFile(tempDir, "sub/deep/c.txt"); - - const files = listDirRecursive(tempDir); - expect(files).toContain("a.txt"); - expect(files).toContain("sub/b.txt"); - expect(files).toContain("sub/deep/c.txt"); - }); - - it("returns empty array for empty directory", () => { - expect(listDirRecursive(tempDir)).toHaveLength(0); - }); - - it("returns empty array for non-existent directory", () => { - expect(listDirRecursive("/nonexistent/xyz")).toHaveLength(0); - }); -}); - -// --------------------------------------------------------------------------- -// Full unpackBundle integration test (using real filesystem) -// --------------------------------------------------------------------------- - -describe("unpackBundle – integration", () => { - let bundleBaseDir; - let outputDir; - - beforeEach(() => { - bundleBaseDir = makeTempDir(); - outputDir = makeTempDir(); - vi.clearAllMocks(); - global.core = mockCore; - global.exec = mockExec; - }); - afterEach(() => { - removeTempDir(bundleBaseDir); - removeTempDir(outputDir); - }); - - /** - * Build a fake extracted bundle directory inside bundleBaseDir: - * bundleBaseDir/ - * fake-archive.tar.gz (empty placeholder – exec mock skips real extraction) - * extracted/ - * pkg-1.0.0/ - * apm.lock.yaml - * .github/skills/my-skill/prompt.md - * .claude/skills/my-skill/CLAUDE.md - * - * The exec mock simulates tar extraction by creating the same structure in the - * tempDir that unpackBundle uses. - */ - function buildFakeBundle({ - repoUrl = "https://github.com/owner/my-skill", - files = [ - { path: ".github/skills/my-skill/prompt.md", content: "# My Skill" }, - { path: ".claude/skills/my-skill/CLAUDE.md", content: "# Claude Skill" }, - ], - deployedFiles = [".github/skills/my-skill/", ".claude/skills/my-skill/"], - } = {}) { - // Write the placeholder tar.gz so findBundleFile succeeds - fs.writeFileSync(path.join(bundleBaseDir, "my-package-1.0.0.tar.gz"), "fake"); - - // Build the lockfile content - const fileLines = deployedFiles.map(f => ` - ${f}`).join("\n"); - const lockfileContent = `lockfile_version: '1' -generated_at: '2024-01-15T10:00:00.000000+00:00' -apm_version: 0.8.5 -dependencies: -- repo_url: ${repoUrl} - host: github.com - resolved_commit: abc123def456 - resolved_ref: main - depth: 1 - package_type: generic - deployed_files: -${fileLines} -pack: - target: claude - format: apm -`; - - // The exec mock will be called with tar -xzf -C - // We intercept it to write our fake extracted structure into tempDir - mockExec.exec.mockImplementation(async (_cmd, args) => { - // args: ['-xzf', bundlePath, '-C', tempDir] - const tempDir = args[3]; - const innerDir = path.join(tempDir, "my-package-1.0.0"); - fs.mkdirSync(innerDir, { recursive: true }); - - // Write lockfile - fs.writeFileSync(path.join(innerDir, "apm.lock.yaml"), lockfileContent); - - // Write deployed files - for (const f of files) { - writeFile(innerDir, f.path.replace(/\/$/, "") + (f.path.endsWith("/") ? "/placeholder" : ""), f.content); - } - - // Write directory structure for directory entries in deployedFiles - for (const df of deployedFiles) { - if (df.endsWith("/")) { - const dirPath = df.replace(/\/$/, ""); - fs.mkdirSync(path.join(innerDir, dirPath), { recursive: true }); - // Write at least one file into each directory - const placeholder = path.join(innerDir, dirPath, "skill.md"); - if (!fs.existsSync(placeholder)) { - fs.writeFileSync(placeholder, "# placeholder"); - } - } - } - }); - } - - it("unpacks a bundle and deploys files to output directory", async () => { - buildFakeBundle(); - - const result = await unpackBundle({ bundleDir: bundleBaseDir, outputDir }); - - expect(result.files).toContain(".github/skills/my-skill/"); - expect(result.files).toContain(".claude/skills/my-skill/"); - expect(result.verified).toBe(true); - expect(result.packMeta.target).toBe("claude"); - - // Verify files were deployed - expect(fs.existsSync(path.join(outputDir, ".github", "skills", "my-skill"))).toBe(true); - expect(fs.existsSync(path.join(outputDir, ".claude", "skills", "my-skill"))).toBe(true); - }); - - it("dry-run resolves files without copying", async () => { - buildFakeBundle(); - - const result = await unpackBundle({ bundleDir: bundleBaseDir, outputDir, dryRun: true }); - - expect(result.files).toContain(".github/skills/my-skill/"); - expect(result.files).toContain(".claude/skills/my-skill/"); - // Nothing should have been deployed - expect(fs.existsSync(path.join(outputDir, ".github"))).toBe(false); - }); - - it("throws when bundle directory is empty", async () => { - await expect(unpackBundle({ bundleDir: bundleBaseDir, outputDir })).rejects.toThrow(/No \*.tar\.gz bundle found/); - }); - - it("throws when lockfile is missing from bundle", async () => { - fs.writeFileSync(path.join(bundleBaseDir, "broken.tar.gz"), "fake"); - - mockExec.exec.mockImplementation(async (_cmd, args) => { - const tempDir = args[3]; - const innerDir = path.join(tempDir, "my-package-1.0.0"); - fs.mkdirSync(innerDir, { recursive: true }); - // No lockfile written – this should trigger an error - }); - - await expect(unpackBundle({ bundleDir: bundleBaseDir, outputDir })).rejects.toThrow(/apm\.lock\.yaml not found/); - }); - - it("handles plain file entries (non-directory deployed_files)", async () => { - buildFakeBundle({ - deployedFiles: [".github/copilot-instructions.md"], - files: [{ path: ".github/copilot-instructions.md", content: "# Instructions" }], - }); - - mockExec.exec.mockImplementation(async (_cmd, args) => { - const tempDir = args[3]; - const innerDir = path.join(tempDir, "my-package-1.0.0"); - fs.mkdirSync(innerDir, { recursive: true }); - - const lockfileContent = `lockfile_version: '1' -dependencies: -- repo_url: https://github.com/owner/repo - deployed_files: - - .github/copilot-instructions.md -`; - fs.writeFileSync(path.join(innerDir, "apm.lock.yaml"), lockfileContent); - writeFile(innerDir, ".github/copilot-instructions.md", "# Instructions"); - }); - - const result = await unpackBundle({ bundleDir: bundleBaseDir, outputDir }); - expect(result.files).toContain(".github/copilot-instructions.md"); - expect(fs.existsSync(path.join(outputDir, ".github", "copilot-instructions.md"))).toBe(true); - }); - - it("throws when bundle contains only legacy apm.lock (not supported)", async () => { - fs.writeFileSync(path.join(bundleBaseDir, "pkg.tar.gz"), "fake"); - - mockExec.exec.mockImplementation(async (_cmd, args) => { - const tempDir = args[3]; - const innerDir = path.join(tempDir, "pkg-1.0.0"); - fs.mkdirSync(innerDir, { recursive: true }); - // Only write the legacy lockfile — should be rejected - fs.writeFileSync(path.join(innerDir, "apm.lock"), "lockfile_version: '1'\ndependencies:\n"); - }); - - await expect(unpackBundle({ bundleDir: bundleBaseDir, outputDir })).rejects.toThrow(/apm\.lock\.yaml not found/); - }); - - it("skips verification when skipVerify is true", async () => { - buildFakeBundle({ deployedFiles: [".github/skills/foo/"] }); - - // Simulate a bundle where the file is missing but skipVerify lets it through - mockExec.exec.mockImplementation(async (_cmd, args) => { - const tempDir = args[3]; - const innerDir = path.join(tempDir, "my-package-1.0.0"); - fs.mkdirSync(innerDir, { recursive: true }); - - const lockfileContent = `lockfile_version: '1' -dependencies: -- repo_url: https://github.com/owner/repo - deployed_files: - - .github/skills/missing-file/ -`; - fs.writeFileSync(path.join(innerDir, "apm.lock.yaml"), lockfileContent); - // Intentionally NOT creating .github/skills/missing-file/ - }); - - const result = await unpackBundle({ bundleDir: bundleBaseDir, outputDir, skipVerify: true }); - expect(result.verified).toBe(false); - expect(result.skippedCount).toBe(1); // missing entry is skipped - }); -}); - -// --------------------------------------------------------------------------- -// Edge cases for YAML parser -// --------------------------------------------------------------------------- - -describe("parseAPMLockfile – edge cases", () => { - it("handles YAML with Windows-style line endings (CRLF)", () => { - const yaml = "lockfile_version: '1'\r\ngenerated_at: '2024-01-15'\r\ndependencies:\r\n"; - const result = parseAPMLockfile(yaml); - // CRLF lines won't match our patterns cleanly, but should not throw - expect(result).toBeDefined(); - }); - - it("handles quoted values with internal spaces", () => { - const yaml = `lockfile_version: '1 (patched)' -dependencies: -`; - const result = parseAPMLockfile(yaml); - expect(result.lockfile_version).toBe("1 (patched)"); - }); - - it("handles multiple dependencies with pack block at the end", () => { - const yaml = `lockfile_version: '1' -dependencies: -- repo_url: https://github.com/a/pkg - deployed_files: - - skill-a/ -- repo_url: https://github.com/b/pkg - deployed_files: - - skill-b/ -pack: - target: all - format: apm -`; - const result = parseAPMLockfile(yaml); - expect(result.dependencies).toHaveLength(2); - expect(result.pack.target).toBe("all"); - }); - - it("does not modify deployed_files paths (preserves trailing slash)", () => { - const yaml = `lockfile_version: '1' -dependencies: -- repo_url: https://github.com/owner/repo - deployed_files: - - .github/skills/my-skill/ - - plain-file.md -`; - const result = parseAPMLockfile(yaml); - expect(result.dependencies[0].deployed_files[0]).toBe(".github/skills/my-skill/"); - expect(result.dependencies[0].deployed_files[1]).toBe("plain-file.md"); - }); -}); diff --git a/actions/setup/js/generate_aw_info.cjs b/actions/setup/js/generate_aw_info.cjs index 863c8038e0d..e984be8ee46 100644 --- a/actions/setup/js/generate_aw_info.cjs +++ b/actions/setup/js/generate_aw_info.cjs @@ -86,12 +86,6 @@ async function main(core, ctx) { awInfo.cli_version = cliVersion; } - // Include apm_version only when APM dependencies are configured - const apmVersion = process.env.GH_AW_INFO_APM_VERSION; - if (apmVersion) { - awInfo.apm_version = apmVersion; - } - // Include aw_context when the workflow was triggered via workflow_dispatch with // the aw_context input set by a calling agentic workflow's dispatch_workflow handler. // Validates JSON format and structure before populating the context key in aw_info.json. diff --git a/actions/setup/js/run_apm_unpack.cjs b/actions/setup/js/run_apm_unpack.cjs deleted file mode 100644 index ab2ac6e93b9..00000000000 --- a/actions/setup/js/run_apm_unpack.cjs +++ /dev/null @@ -1,65 +0,0 @@ -// @ts-check -/** - * run_apm_unpack.cjs - * - * Standalone entry-point for apm_unpack.cjs used in CI integration tests. - * Sets up lightweight CJS-compatible shims for the @actions/* globals expected - * by apm_unpack.cjs, then calls main(). - * - * The @actions/core v3+ package is ESM-only and cannot be loaded via require(). - * The shims below reproduce the subset of the API that apm_unpack.cjs uses: - * core.info / core.warning / core.error / core.setFailed / core.setOutput - * exec.exec(cmd, args, options) - * - * Environment variables (consumed by apm_unpack.main): - * APM_BUNDLE_DIR – directory containing the *.tar.gz bundle - * OUTPUT_DIR – destination directory for deployed files - * - * Usage: - * node actions/setup/js/run_apm_unpack.cjs - */ - -"use strict"; - -const { spawnSync } = require("child_process"); -const { setupGlobals } = require("./setup_globals.cjs"); -const { main } = require("./apm_unpack.cjs"); - -// Minimal shim for @actions/core — only the methods used by apm_unpack.cjs. -const core = { - info: msg => console.log(msg), - warning: msg => console.warn(`::warning::${msg}`), - error: msg => console.error(`::error::${msg}`), - setFailed: msg => { - console.error(`::error::${msg}`); - process.exitCode = 1; - }, - setOutput: (name, value) => console.log(`::set-output name=${name}::${value}`), -}; - -// Minimal shim for @actions/exec — only exec() is used by apm_unpack.cjs. -const exec = { - exec: async (cmd, args = [], opts = {}) => { - const result = spawnSync(cmd, args, { stdio: "inherit", ...opts }); - if (result.status !== 0) { - throw new Error(`Command failed: ${cmd} ${args.join(" ")} (exit ${result.status})`); - } - return result.status; - }, -}; - -// Wire shims into globals so apm_unpack.cjs can use them. -// Passing empty objects for github (GraphQL client) and context (event payload) -// because apm_unpack does not use GitHub API or event metadata. -setupGlobals( - core, // logging, outputs, inputs - {}, // @actions/github – not used by apm_unpack - {}, // GitHub Actions event context – not used by apm_unpack - exec, // runs `tar -xzf` - {} // @actions/io – not used by apm_unpack -); - -main().catch(err => { - console.error(`::error::${err.message}`); - process.exit(1); -}); diff --git a/pkg/cli/codemod_dependencies.go b/pkg/cli/codemod_dependencies.go deleted file mode 100644 index 2ca8c21a963..00000000000 --- a/pkg/cli/codemod_dependencies.go +++ /dev/null @@ -1,193 +0,0 @@ -package cli - -import ( - "strings" - - "github.com/github/gh-aw/pkg/logger" -) - -var dependenciesCodemodLog = logger.New("cli:codemod_dependencies") - -// getDependenciesToImportsAPMPackagesCodemod creates a codemod that migrates the top-level -// `dependencies:` field to `imports.apm-packages:`. The `dependencies:` field is deprecated -// in favour of `imports.apm-packages:`, which co-locates APM package configuration alongside -// shared agentic workflow imports under the unified `imports` key. -// -// Migration rules: -// - The entire `dependencies:` block (array or object form) is moved to `imports.apm-packages:` -// - If `imports` is absent, a new `imports:` block is created with `apm-packages:` inside it -// - If `imports` is an array, it is converted to the object form with the existing items -// placed under `aw:` and the dependencies placed under `apm-packages:` -// - If `imports` is already an object, `apm-packages:` is added to it -// - If `imports.apm-packages` already exists the codemod is skipped to avoid clobbering -func getDependenciesToImportsAPMPackagesCodemod() Codemod { - return Codemod{ - ID: "dependencies-to-imports-apm-packages", - Name: "Migrate dependencies to imports.apm-packages", - Description: "Moves the top-level 'dependencies' field to 'imports.apm-packages'. The 'dependencies' field is deprecated in favour of 'imports.apm-packages'.", - IntroducedIn: "1.18.0", - Apply: func(content string, frontmatter map[string]any) (string, bool, error) { - _, hasDeps := frontmatter["dependencies"] - if !hasDeps { - return content, false, nil - } - - // Skip if imports.apm-packages already exists to avoid clobbering user config. - if importsAny, hasImports := frontmatter["imports"]; hasImports { - if importsMap, ok := importsAny.(map[string]any); ok { - if _, hasAPM := importsMap["apm-packages"]; hasAPM { - dependenciesCodemodLog.Print("'imports.apm-packages' already exists – skipping migration to avoid overwriting") - return content, false, nil - } - } - } - - return applyFrontmatterLineTransform(content, func(lines []string) ([]string, bool) { - return migrateDependenciesToImportsAPMPackages(lines, frontmatter) - }) - }, - } -} - -// migrateDependenciesToImportsAPMPackages rewrites the frontmatter lines to move the -// `dependencies:` block to `imports.apm-packages:`, handling three cases: -// 1. No `imports` field: create `imports:\n apm-packages:\n ...` -// 2. `imports` is an array: convert to object form with `aw:` and `apm-packages:` -// 3. `imports` is already an object: append `apm-packages:` to it -func migrateDependenciesToImportsAPMPackages(lines []string, frontmatter map[string]any) ([]string, bool) { - // Locate the dependencies: block. - depsIdx, depsEnd := findTopLevelBlock(lines, "dependencies") - if depsIdx == -1 { - return lines, false - } - depsIndent := getIndentation(lines[depsIdx]) - // depsBodyRaw are the raw lines of the dependencies block body (everything after the key line). - depsBodyRaw := lines[depsIdx+1 : depsEnd] - // The body items are indented by depsIndent+" " in the original file. - depsBodyItemIndent := depsIndent + " " - - // Locate the imports: block (if any). - importsIdx, importsEnd := findTopLevelBlock(lines, "imports") - - // Determine the current form of imports (absent / array / object). - _, importsIsObject := frontmatter["imports"].(map[string]any) - - var result []string - - switch { - case importsIdx == -1: - // Case 1: No imports field — replace dependencies block with imports block. - result = make([]string, 0, len(lines)+len(depsBodyRaw)+2) - for i, line := range lines { - if i == depsIdx { - result = append(result, "imports:") - result = append(result, " apm-packages:") - result = append(result, reindentBlock(depsBodyRaw, depsBodyItemIndent, " ")...) - continue - } - if i > depsIdx && i < depsEnd { - continue - } - result = append(result, line) - } - - case !importsIsObject: - // Case 2: imports is an array — convert to object form with aw and apm-packages. - importsBodyRaw := lines[importsIdx+1 : importsEnd] - // Imports body items are indented by 2 spaces (top-level imports). - importsBodyItemIndent := " " - - result = make([]string, 0, len(lines)+len(importsBodyRaw)+len(depsBodyRaw)+3) - - insertedImports := false - for i, line := range lines { - if i >= importsIdx && i < importsEnd { - if i == importsIdx && !insertedImports { - result = append(result, "imports:") - result = append(result, " aw:") - result = append(result, reindentBlock(importsBodyRaw, importsBodyItemIndent, " ")...) - result = append(result, " apm-packages:") - result = append(result, reindentBlock(depsBodyRaw, depsBodyItemIndent, " ")...) - insertedImports = true - } - continue - } - if i >= depsIdx && i < depsEnd { - continue - } - result = append(result, line) - } - - default: - // Case 3: imports is already an object — append apm-packages to it. - result = make([]string, 0, len(lines)+len(depsBodyRaw)+2) - - for i, line := range lines { - if i == importsEnd { - result = append(result, " apm-packages:") - result = append(result, reindentBlock(depsBodyRaw, depsBodyItemIndent, " ")...) - } - if i >= depsIdx && i < depsEnd { - continue - } - result = append(result, line) - } - } - - dependenciesCodemodLog.Print("Migrated 'dependencies' to 'imports.apm-packages'") - return result, true -} - -// findTopLevelBlock returns the start index (inclusive) and end index (exclusive) -// of the top-level YAML block with the given key name. Returns (-1, -1) if not found. -func findTopLevelBlock(lines []string, key string) (startIdx, endIdx int) { - startIdx = -1 - for i, line := range lines { - if isTopLevelKey(line) && strings.HasPrefix(strings.TrimSpace(line), key+":") { - startIdx = i - break - } - } - if startIdx == -1 { - return -1, -1 - } - blockIndent := getIndentation(lines[startIdx]) - endIdx = startIdx + 1 - for endIdx < len(lines) { - line := lines[endIdx] - trimmed := strings.TrimSpace(line) - if trimmed == "" || strings.HasPrefix(trimmed, "#") { - endIdx++ - continue - } - if isNestedUnder(line, blockIndent) { - endIdx++ - continue - } - break - } - return startIdx, endIdx -} - -// reindentBlock changes the indentation prefix of a set of lines from oldPrefix to newPrefix. -// Lines whose indentation does not start with oldPrefix are left unchanged (safe fallback). -func reindentBlock(lines []string, oldPrefix, newPrefix string) []string { - result := make([]string, 0, len(lines)) - for _, line := range lines { - trimmed := strings.TrimSpace(line) - if trimmed == "" { - result = append(result, line) - continue - } - currentIndent := getIndentation(line) - // Only reindent lines whose current indent starts with oldPrefix. - if !strings.HasPrefix(currentIndent, oldPrefix) { - result = append(result, line) - continue - } - // Compute how many extra spaces this line has beyond the old prefix length. - extra := currentIndent[len(oldPrefix):] - result = append(result, newPrefix+extra+trimmed) - } - return result -} diff --git a/pkg/cli/codemod_dependencies_test.go b/pkg/cli/codemod_dependencies_test.go deleted file mode 100644 index 472be6759f1..00000000000 --- a/pkg/cli/codemod_dependencies_test.go +++ /dev/null @@ -1,235 +0,0 @@ -//go:build !integration - -package cli - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestGetDependenciesToImportsAPMPackagesCodemod(t *testing.T) { - codemod := getDependenciesToImportsAPMPackagesCodemod() - - assert.Equal(t, "dependencies-to-imports-apm-packages", codemod.ID) - assert.Equal(t, "Migrate dependencies to imports.apm-packages", codemod.Name) - assert.NotEmpty(t, codemod.Description) - assert.Equal(t, "1.18.0", codemod.IntroducedIn) - require.NotNil(t, codemod.Apply) -} - -func TestDependenciesToImportsAPMPackagesCodemod_NoDependencies(t *testing.T) { - codemod := getDependenciesToImportsAPMPackagesCodemod() - - content := `--- -on: workflow_dispatch -engine: copilot ---- - -# No dependencies` - - frontmatter := map[string]any{ - "on": "workflow_dispatch", - "engine": "copilot", - } - - result, applied, err := codemod.Apply(content, frontmatter) - - require.NoError(t, err) - assert.False(t, applied, "Codemod should not be applied when dependencies is absent") - assert.Equal(t, content, result, "Content should not be modified") -} - -func TestDependenciesToImportsAPMPackagesCodemod_SimpleArray_NoImports(t *testing.T) { - codemod := getDependenciesToImportsAPMPackagesCodemod() - - content := `--- -on: - issues: - types: [opened] -engine: copilot -dependencies: - - microsoft/apm-sample-package - - github/awesome-copilot ---- - -# Test workflow` - - frontmatter := map[string]any{ - "on": map[string]any{"issues": map[string]any{"types": []any{"opened"}}}, - "engine": "copilot", - "dependencies": []any{"microsoft/apm-sample-package", "github/awesome-copilot"}, - } - - result, applied, err := codemod.Apply(content, frontmatter) - - require.NoError(t, err) - assert.True(t, applied, "Codemod should have been applied") - assert.NotContains(t, result, "dependencies:", "dependencies key should be removed") - assert.Contains(t, result, "imports:", "imports key should be present") - assert.Contains(t, result, "apm-packages:", "apm-packages key should be present") - assert.Contains(t, result, "- microsoft/apm-sample-package", "first package should be present") - assert.Contains(t, result, "- github/awesome-copilot", "second package should be present") -} - -func TestDependenciesToImportsAPMPackagesCodemod_ObjectFormat_NoImports(t *testing.T) { - codemod := getDependenciesToImportsAPMPackagesCodemod() - - content := `--- -on: - issues: - types: [opened] -engine: copilot -dependencies: - packages: - - acme-org/acme-skills - github-app: - app-id: ${{ vars.APP_ID }} - private-key: ${{ secrets.APP_PRIVATE_KEY }} ---- - -# Test workflow` - - frontmatter := map[string]any{ - "on": map[string]any{"issues": map[string]any{"types": []any{"opened"}}}, - "engine": "copilot", - "dependencies": map[string]any{ - "packages": []any{"acme-org/acme-skills"}, - "github-app": map[string]any{ - "app-id": "${{ vars.APP_ID }}", - "private-key": "${{ secrets.APP_PRIVATE_KEY }}", - }, - }, - } - - result, applied, err := codemod.Apply(content, frontmatter) - - require.NoError(t, err) - assert.True(t, applied, "Codemod should have been applied") - assert.NotContains(t, result, "dependencies:", "dependencies key should be removed") - assert.Contains(t, result, "imports:", "imports key should be present") - assert.Contains(t, result, "apm-packages:", "apm-packages key should be present") - assert.Contains(t, result, "packages:", "packages sub-key should be preserved") - assert.Contains(t, result, "github-app:", "github-app sub-key should be preserved") - assert.Contains(t, result, "acme-org/acme-skills", "package should be preserved") -} - -func TestDependenciesToImportsAPMPackagesCodemod_WithExistingArrayImports(t *testing.T) { - codemod := getDependenciesToImportsAPMPackagesCodemod() - - content := `--- -on: workflow_dispatch -imports: - - shared/common.md - - shared/tools.md -dependencies: - - microsoft/apm-sample-package ---- - -# Test workflow` - - frontmatter := map[string]any{ - "on": "workflow_dispatch", - "imports": []any{"shared/common.md", "shared/tools.md"}, - "dependencies": []any{"microsoft/apm-sample-package"}, - } - - result, applied, err := codemod.Apply(content, frontmatter) - - require.NoError(t, err) - assert.True(t, applied, "Codemod should have been applied") - assert.NotContains(t, result, "dependencies:", "dependencies key should be removed") - assert.Contains(t, result, "imports:", "imports key should be present") - assert.Contains(t, result, "aw:", "aw subfield should be present") - assert.Contains(t, result, "apm-packages:", "apm-packages key should be present") - assert.Contains(t, result, "shared/common.md", "existing import should be preserved") - assert.Contains(t, result, "shared/tools.md", "existing import should be preserved") - assert.Contains(t, result, "microsoft/apm-sample-package", "package should be present") -} - -func TestDependenciesToImportsAPMPackagesCodemod_WithExistingObjectImports(t *testing.T) { - codemod := getDependenciesToImportsAPMPackagesCodemod() - - content := `--- -on: workflow_dispatch -imports: - aw: - - shared/common.md -dependencies: - - microsoft/apm-sample-package ---- - -# Test workflow` - - frontmatter := map[string]any{ - "on": "workflow_dispatch", - "imports": map[string]any{ - "aw": []any{"shared/common.md"}, - }, - "dependencies": []any{"microsoft/apm-sample-package"}, - } - - result, applied, err := codemod.Apply(content, frontmatter) - - require.NoError(t, err) - assert.True(t, applied, "Codemod should have been applied") - assert.NotContains(t, result, "dependencies:", "dependencies key should be removed") - assert.Contains(t, result, "imports:", "imports key should be present") - assert.Contains(t, result, "apm-packages:", "apm-packages key should be added") - assert.Contains(t, result, "shared/common.md", "existing aw import should be preserved") - assert.Contains(t, result, "microsoft/apm-sample-package", "package should be present") -} - -func TestDependenciesToImportsAPMPackagesCodemod_SkipsWhenAPMPackagesExist(t *testing.T) { - codemod := getDependenciesToImportsAPMPackagesCodemod() - - content := `--- -on: workflow_dispatch -imports: - apm-packages: - - existing/package -dependencies: - - microsoft/apm-sample-package ----` - - frontmatter := map[string]any{ - "on": "workflow_dispatch", - "imports": map[string]any{ - "apm-packages": []any{"existing/package"}, - }, - "dependencies": []any{"microsoft/apm-sample-package"}, - } - - result, applied, err := codemod.Apply(content, frontmatter) - - require.NoError(t, err) - assert.False(t, applied, "Codemod should be skipped when imports.apm-packages already exists") - assert.Equal(t, content, result, "Content should not be modified") -} - -func TestDependenciesToImportsAPMPackagesCodemod_PreservesMarkdownBody(t *testing.T) { - codemod := getDependenciesToImportsAPMPackagesCodemod() - - content := `--- -engine: copilot -dependencies: - - microsoft/apm-sample-package ---- - -# My workflow - -Use the skills provided.` - - frontmatter := map[string]any{ - "engine": "copilot", - "dependencies": []any{"microsoft/apm-sample-package"}, - } - - result, applied, err := codemod.Apply(content, frontmatter) - - require.NoError(t, err) - assert.True(t, applied, "Codemod should have been applied") - assert.Contains(t, result, "# My workflow", "Markdown body should be preserved") - assert.Contains(t, result, "Use the skills provided.", "Markdown body should be preserved") -} diff --git a/pkg/cli/fix_codemods.go b/pkg/cli/fix_codemods.go index 2e6e4c29fb8..9d34534dae6 100644 --- a/pkg/cli/fix_codemods.go +++ b/pkg/cli/fix_codemods.go @@ -50,7 +50,6 @@ func GetAllCodemods() []Codemod { getSafeInputsToMCPScriptsCodemod(), // Rename safe-inputs to mcp-scripts getPluginsToDependenciesCodemod(), // Migrate plugins to dependencies (plugins removed in favour of APM) getGitHubReposToAllowedReposCodemod(), // Rename deprecated tools.github.repos to tools.github.allowed-repos - getDependenciesToImportsAPMPackagesCodemod(), // Migrate dependencies to imports.apm-packages (dependencies deprecated) } fixCodemodsLog.Printf("Loaded codemod registry: %d codemods available", len(codemods)) return codemods diff --git a/pkg/cli/fix_codemods_test.go b/pkg/cli/fix_codemods_test.go index 521340fb629..e316943126f 100644 --- a/pkg/cli/fix_codemods_test.go +++ b/pkg/cli/fix_codemods_test.go @@ -43,7 +43,7 @@ func TestGetAllCodemods_ReturnsAllCodemods(t *testing.T) { codemods := GetAllCodemods() // Verify we have the expected number of codemods - expectedCount := 29 + expectedCount := 28 assert.Len(t, codemods, expectedCount, "Should return all %d codemods", expectedCount) // Verify all codemods have required fields @@ -133,7 +133,6 @@ func TestGetAllCodemods_InExpectedOrder(t *testing.T) { "safe-inputs-to-mcp-scripts", "plugins-to-dependencies", "github-repos-to-allowed-repos", - "dependencies-to-imports-apm-packages", } require.Len(t, codemods, len(expectedOrder), "Should have expected number of codemods") diff --git a/pkg/cli/imports.go b/pkg/cli/imports.go index 1c4e0bc149c..3e3ebba9c64 100644 --- a/pkg/cli/imports.go +++ b/pkg/cli/imports.go @@ -128,7 +128,6 @@ func processImportsWithWorkflowSpec(content string, workflow *WorkflowSpec, comm v["aw"] = processImportPaths(aw) } } - // apm-packages subfield contains package names (not file paths) — leave as-is. default: importsLog.Print("Invalid imports field type, skipping") return content, nil diff --git a/pkg/cli/workflows/test-top-level-github-app-dependencies.md b/pkg/cli/workflows/test-top-level-github-app-dependencies.md deleted file mode 100644 index 098ecbfc91d..00000000000 --- a/pkg/cli/workflows/test-top-level-github-app-dependencies.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -on: - issues: - types: [opened] -permissions: - contents: read - -github-app: - app-id: ${{ vars.APP_ID }} - private-key: ${{ secrets.APP_PRIVATE_KEY }} -dependencies: - packages: - - myorg/private-skill -safe-outputs: - create-issue: - title-prefix: "[automated] " -engine: copilot ---- - -# Top-Level GitHub App Fallback for APM Dependencies - -This workflow demonstrates using a top-level github-app as a fallback for APM dependencies. - -The top-level `github-app` is automatically applied to APM package installations when no -`dependencies.github-app` is configured. This allows installing APM packages from private -repositories across organizations using the GitHub App installation token. diff --git a/pkg/constants/constants.go b/pkg/constants/constants.go index f719b332047..beadf2e287f 100644 --- a/pkg/constants/constants.go +++ b/pkg/constants/constants.go @@ -409,12 +409,6 @@ const DefaultMCPGatewayPayloadSizeThreshold = 524288 // DefaultFirewallRegistry is the container image registry for AWF (gh-aw-firewall) Docker images const DefaultFirewallRegistry = "ghcr.io/github/gh-aw-firewall" -// DefaultAPMActionVersion is the default version of the microsoft/apm-action GitHub Action -const DefaultAPMActionVersion Version = "v1.4.1" - -// DefaultAPMVersion is the default version of the microsoft/APM (Agent Package Manager) CLI -const DefaultAPMVersion Version = "v0.8.6" - // DefaultPlaywrightMCPVersion is the default version of the @playwright/mcp package const DefaultPlaywrightMCPVersion Version = "0.0.68" @@ -625,7 +619,6 @@ var DangerousPropertyNames = []string{ const AgentJobName JobName = "agent" const ActivationJobName JobName = "activation" -const APMJobName JobName = "apm" const IndexingJobName JobName = "indexing" const PreActivationJobName JobName = "pre_activation" const DetectionJobName JobName = "detection" @@ -664,9 +657,6 @@ const ArtifactPrefixOutputName = "artifact_prefix" // (aw_info.json and prompt.txt). const ActivationArtifactName = "activation" -// APMArtifactName is the artifact name for the APM (Agent Package Manager) bundle. -const APMArtifactName = "apm" - // SafeOutputItemsArtifactName is the artifact name for the safe output items manifest. // This artifact contains the JSONL manifest of all items created by safe output handlers // and is uploaded by the safe_outputs job to avoid conflicting with the "agent" artifact diff --git a/pkg/parser/import_field_extractor.go b/pkg/parser/import_field_extractor.go index 2eff5c24bee..8c7a368e73b 100644 --- a/pkg/parser/import_field_extractor.go +++ b/pkg/parser/import_field_extractor.go @@ -309,6 +309,12 @@ func (acc *importAccumulator) extractAllImportFields(content []byte, item import acc.postStepsBuilder.WriteString(postStepsContent + "\n") } + // Extract jobs from imported file (append in order; merged into custom jobs map) + jobsContent, err := extractFieldJSONFromMap(fm, "jobs", "{}") + if err == nil && jobsContent != "" && jobsContent != "{}" { + acc.jobsBuilder.WriteString(jobsContent + "\n") + } + // Extract labels from imported file (merge into set to avoid duplicates) labelsContent, err := extractFieldJSONFromMap(fm, "labels", "[]") if err == nil && labelsContent != "" && labelsContent != "[]" { diff --git a/pkg/parser/import_field_extractor_test.go b/pkg/parser/import_field_extractor_test.go index 7b60b6a4d99..e80fccfdcca 100644 --- a/pkg/parser/import_field_extractor_test.go +++ b/pkg/parser/import_field_extractor_test.go @@ -3,6 +3,8 @@ package parser import ( + "os" + "path/filepath" "testing" "github.com/stretchr/testify/assert" @@ -140,3 +142,61 @@ func TestComputeImportRelPath(t *testing.T) { }) } } + +// TestJobsFieldExtractedFromMdImport verifies that jobs: in a shared .md workflow's +// frontmatter is captured into ImportsResult.MergedJobs and merged correctly. +func TestJobsFieldExtractedFromMdImport(t *testing.T) { + tmpDir := t.TempDir() + + // Create a shared .md workflow with a jobs: section + sharedContent := `--- +name: Shared APM Workflow +jobs: + apm: + runs-on: ubuntu-slim + needs: [activation] + permissions: {} + steps: + - name: Pack + uses: microsoft/apm-action@v1.4.1 + with: + pack: 'true' +--- + +# APM shared workflow +` + sharedDir := filepath.Join(tmpDir, "shared") + if err := os.MkdirAll(sharedDir, 0755); err != nil { + t.Fatalf("Failed to create shared dir: %v", err) + } + if err := os.WriteFile(filepath.Join(sharedDir, "apm.md"), []byte(sharedContent), 0644); err != nil { + t.Fatalf("Failed to write shared file: %v", err) + } + + // Create a main .md workflow that imports the shared workflow + mainContent := `--- +name: Main Workflow +on: issue_comment +imports: + - uses: shared/apm.md + with: + packages: + - microsoft/apm-sample-package +--- + +# Main Workflow +` + result, err := ExtractFrontmatterFromContent(mainContent) + if err != nil { + t.Fatalf("ExtractFrontmatterFromContent() error = %v", err) + } + + importsResult, err := ProcessImportsFromFrontmatterWithSource(result.Frontmatter, tmpDir, nil, "", "") + if err != nil { + t.Fatalf("ProcessImportsFromFrontmatterWithSource() error = %v", err) + } + + assert.NotEmpty(t, importsResult.MergedJobs, "MergedJobs should be populated from shared .md import") + assert.Contains(t, importsResult.MergedJobs, "apm", "MergedJobs should contain the 'apm' job") + assert.Contains(t, importsResult.MergedJobs, "ubuntu-slim", "MergedJobs should contain the job runner") +} diff --git a/pkg/parser/include_processor.go b/pkg/parser/include_processor.go index 37f8ddeac68..87c1ee6fb59 100644 --- a/pkg/parser/include_processor.go +++ b/pkg/parser/include_processor.go @@ -158,6 +158,7 @@ func processIncludedFileWithVisited(filePath, sectionName string, extractTools b "name": true, "description": true, "steps": true, + "jobs": true, "safe-outputs": true, "mcp-scripts": true, "services": true, diff --git a/pkg/workflow/apm_dependencies.go b/pkg/workflow/apm_dependencies.go deleted file mode 100644 index 400ce19d827..00000000000 --- a/pkg/workflow/apm_dependencies.go +++ /dev/null @@ -1,234 +0,0 @@ -package workflow - -import ( - "fmt" - "sort" - - "github.com/github/gh-aw/pkg/constants" - "github.com/github/gh-aw/pkg/logger" -) - -var apmDepsLog = logger.New("workflow:apm_dependencies") - -// apmAppTokenStepID is the step ID for the GitHub App token mint step used by APM dependencies. -const apmAppTokenStepID = "apm-app-token" - -// getEffectiveAPMGitHubToken returns the GitHub token expression to use for APM pack authentication. -// Priority (highest to lowest): -// 1. Custom token from dependencies.github-token field -// 2. secrets.GH_AW_PLUGINS_TOKEN (token dedicated for plugin/package operations) -// 3. secrets.GH_AW_GITHUB_TOKEN (general-purpose gh-aw token) -// 4. secrets.GITHUB_TOKEN (default GitHub Actions token) -func getEffectiveAPMGitHubToken(customToken string) string { - if customToken != "" { - apmDepsLog.Print("Using custom APM GitHub token (from dependencies.github-token)") - return customToken - } - apmDepsLog.Print("Using cascading APM GitHub token (GH_AW_PLUGINS_TOKEN || GH_AW_GITHUB_TOKEN || GITHUB_TOKEN)") - return "${{ secrets.GH_AW_PLUGINS_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}" -} - -// buildAPMAppTokenMintStep generates the step to mint a GitHub App installation access token -// for use by the APM pack step to access cross-org private repositories. -// -// Parameters: -// - app: GitHub App configuration containing app-id, private-key, owner, and repositories -// - fallbackRepoExpr: expression used as the repositories value when app.Repositories is empty. -// Pass "${{ steps.resolve-host-repo.outputs.target_repo_name }}" for workflow_call relay -// workflows so the token is scoped to the platform (host) repo rather than the caller repo. -// Pass "" to use the default "${{ github.event.repository.name }}" fallback. -// -// Returns a slice of YAML step lines. -func buildAPMAppTokenMintStep(app *GitHubAppConfig, fallbackRepoExpr string) []string { - apmDepsLog.Printf("Building APM 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 for APM dependencies\n") - steps = append(steps, fmt.Sprintf(" id: %s\n", apmAppTokenStepID)) - 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 - // - If repositories is empty/not specified, default to the current repository - if len(app.Repositories) == 1 && app.Repositories[0] == "*" { - // Org-wide access: omit repositories field entirely - apmDepsLog.Print("Using org-wide GitHub App token for APM (repositories: *)") - } else if len(app.Repositories) == 1 { - steps = append(steps, fmt.Sprintf(" repositories: %s\n", app.Repositories[0])) - } else if len(app.Repositories) > 1 { - steps = append(steps, " repositories: |-\n") - reposCopy := make([]string, len(app.Repositories)) - copy(reposCopy, app.Repositories) - sort.Strings(reposCopy) - for _, repo := range reposCopy { - 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 steps.resolve-host-repo.outputs.target_repo_name - // so the token is scoped to the platform (host) repo name rather than the full owner/repo slug. - 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") - - return steps -} - -// buildAPMAppTokenInvalidationStep generates the step to invalidate the GitHub App token -// that was minted for APM cross-org repository access. This step always runs (even on failure) -// to ensure the token is properly cleaned up after the APM pack step completes. -func buildAPMAppTokenInvalidationStep() []string { - var steps []string - - steps = append(steps, " - name: Invalidate GitHub App token for APM\n") - steps = append(steps, fmt.Sprintf(" if: always() && steps.%s.outputs.token != ''\n", apmAppTokenStepID)) - steps = append(steps, " env:\n") - steps = append(steps, fmt.Sprintf(" TOKEN: ${{ steps.%s.outputs.token }}\n", apmAppTokenStepID)) - steps = append(steps, " run: |\n") - steps = append(steps, " echo \"Revoking GitHub App installation token for APM...\"\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 revocation failed (token may be expired or invalid).\"\n") - steps = append(steps, " echo \"Token invalidation step complete.\"\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 { - 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) - - actionRef, err := GetActionPinWithData("microsoft/apm-action", string(constants.DefaultAPMActionVersion), data) - if err != nil { - apmDepsLog.Printf("Failed to resolve microsoft/apm-action@%s: %v", constants.DefaultAPMActionVersion, err) - actionRef = GetActionPin("microsoft/apm-action") - } - - lines := []string{ - " - name: Install and pack APM dependencies", - " id: apm_pack", - " uses: " + actionRef, - } - - // Build env block: always add GITHUB_TOKEN (app token takes priority over cascading fallback) - // plus any user-provided env vars. - // If github-app is configured, GITHUB_TOKEN is set from the minted app token, so any - // user-supplied GITHUB_TOKEN key is skipped to avoid a duplicate / conflicting entry. - hasGitHubAppToken := apmDeps.GitHubApp != nil - hasUserEnv := len(apmDeps.Env) > 0 - lines = append(lines, " env:") - if hasGitHubAppToken { - lines = append(lines, - fmt.Sprintf(" GITHUB_TOKEN: ${{ steps.%s.outputs.token }}", apmAppTokenStepID), - ) - } else { - // No github-app: use cascading token fallback (custom token or GH_AW_PLUGINS_TOKEN cascade) - lines = append(lines, - " GITHUB_TOKEN: "+getEffectiveAPMGitHubToken(apmDeps.GitHubToken), - ) - } - if hasUserEnv { - keys := make([]string, 0, len(apmDeps.Env)) - for k := range apmDeps.Env { - // Skip GITHUB_TOKEN when github-app provides it to avoid duplicate keys - if hasGitHubAppToken && k == "GITHUB_TOKEN" { - continue - } - keys = append(keys, k) - } - sort.Strings(keys) - for _, k := range keys { - lines = append(lines, fmt.Sprintf(" %s: %s", k, apmDeps.Env[k])) - } - } - - lines = append(lines, - " with:", - " dependencies: |", - ) - - for _, dep := range apmDeps.Packages { - lines = append(lines, " - "+dep) - } - - lines = append(lines, - " isolated: 'true'", - " pack: 'true'", - " archive: 'true'", - " target: "+target, - " working-directory: /tmp/gh-aw/apm-workspace", - " apm-version: ${{ env.GH_AW_INFO_APM_VERSION }}", - ) - - return GitHubActionStep(lines) -} - -// GenerateAPMRestoreStep generates the GitHub Actions step that restores APM packages -// from a pre-packed bundle in the agent job. -// -// The restore step uses the JavaScript implementation in apm_unpack.cjs (actions/setup/js) -// via actions/github-script, removing the dependency on microsoft/apm-action for -// the unpack phase. Packing still uses microsoft/apm-action in the dedicated APM 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 using JS unpacker (isolated=%v)", apmDeps.Isolated) - - lines := []string{ - " - name: Restore APM dependencies", - " uses: " + GetActionPin("actions/github-script"), - " env:", - " APM_BUNDLE_DIR: /tmp/gh-aw/apm-bundle", - " with:", - " script: |", - " const { setupGlobals } = require('" + SetupActionDestination + "/setup_globals.cjs');", - " setupGlobals(core, github, context, exec, io);", - " const { main } = require('" + SetupActionDestination + "/apm_unpack.cjs');", - " await main();", - } - - return GitHubActionStep(lines) -} diff --git a/pkg/workflow/apm_dependencies_compilation_test.go b/pkg/workflow/apm_dependencies_compilation_test.go deleted file mode 100644 index cfe8dfb4fba..00000000000 --- a/pkg/workflow/apm_dependencies_compilation_test.go +++ /dev/null @@ -1,284 +0,0 @@ -//go:build integration - -package workflow - -import ( - "os" - "path/filepath" - "strings" - "testing" - - "github.com/github/gh-aw/pkg/testutil" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestAPMDependenciesCompilationSinglePackage(t *testing.T) { - tmpDir := testutil.TempDir(t, "apm-deps-single-test") - - workflow := `--- -engine: copilot -on: workflow_dispatch -permissions: - issues: read - pull-requests: read -dependencies: - - microsoft/apm-sample-package ---- - -Test with a single APM dependency -` - - testFile := filepath.Join(tmpDir, "test-apm-single.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) - - // APM job should have the pack step (not the activation job) - assert.Contains(t, lockContent, "Install and pack APM dependencies", - "Lock file should contain APM pack step in APM job") - assert.Contains(t, lockContent, "microsoft/apm-action", - "Lock file should reference the microsoft/apm-action action") - 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") - - // APM artifact upload in APM 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'") - - // APM job should exist with minimal permissions and needs activation - assert.Contains(t, lockContent, "apm:", - "Lock file should contain a dedicated APM job") - assert.Contains(t, lockContent, "permissions: {}", - "APM job should have minimal (empty) permissions") - - // 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, "APM_BUNDLE_DIR: /tmp/gh-aw/apm-bundle", - "Lock file should configure bundle directory for JS unpacker") - assert.Contains(t, lockContent, "apm_unpack.cjs", - "Lock file should use JS unpacker script") - - // 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) { - tmpDir := testutil.TempDir(t, "apm-deps-multi-test") - - workflow := `--- -engine: copilot -on: workflow_dispatch -permissions: - issues: read - pull-requests: read -dependencies: - - microsoft/apm-sample-package - - github/awesome-copilot/skills/review-and-refactor - - anthropics/skills/skills/frontend-design ---- - -Test with multiple APM dependencies -` - - testFile := filepath.Join(tmpDir, "test-apm-multi.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, "microsoft/apm-action", - "Lock file should reference the microsoft/apm-action action") - assert.Contains(t, lockContent, "- microsoft/apm-sample-package", - "Lock file should include first dependency") - assert.Contains(t, lockContent, "- github/awesome-copilot/skills/review-and-refactor", - "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) { - tmpDir := testutil.TempDir(t, "apm-deps-none-test") - - workflow := `--- -engine: copilot -on: workflow_dispatch -permissions: - issues: read - pull-requests: read ---- - -Test without APM dependencies -` - - testFile := filepath.Join(tmpDir, "test-apm-none.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.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 uses the JS unpacker (isolated flag not required for JS implementation) - assert.Contains(t, lockContent, "apm_unpack.cjs", - "Lock file restore step should use the JS unpacker") -} - -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") -} - -func TestAPMDependenciesCompilationWithEnv(t *testing.T) { - tmpDir := testutil.TempDir(t, "apm-deps-env-test") - - workflow := `--- -engine: copilot -on: workflow_dispatch -permissions: - issues: read - pull-requests: read -dependencies: - packages: - - microsoft/apm-sample-package - env: - MY_TOKEN: ${{ secrets.MY_TOKEN }} - REGISTRY: https://registry.example.com ---- - -Test with env vars on APM pack step -` - - testFile := filepath.Join(tmpDir, "test-apm-env.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, "MY_TOKEN:", - "Lock file should include MY_TOKEN env var on pack step") - assert.Contains(t, lockContent, "REGISTRY: https://registry.example.com", - "Lock file should include REGISTRY env var on pack step") -} diff --git a/pkg/workflow/apm_dependencies_test.go b/pkg/workflow/apm_dependencies_test.go deleted file mode 100644 index 8bc7081a22e..00000000000 --- a/pkg/workflow/apm_dependencies_test.go +++ /dev/null @@ -1,987 +0,0 @@ -//go:build !integration - -package workflow - -import ( - "fmt" - "strings" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// combineStepLines joins a GitHubActionStep slice into a single string for assertion. -func combineStepLines(step []string) string { - var sb strings.Builder - for _, line := range step { - sb.WriteString(line + "\n") - } - return sb.String() -} - -func TestExtractAPMDependenciesFromFrontmatter(t *testing.T) { - tests := []struct { - name string - frontmatter map[string]any - expectedDeps []string - expectedIsolated bool - }{ - { - name: "No dependencies field", - frontmatter: map[string]any{ - "engine": "copilot", - }, - expectedDeps: nil, - }, - { - name: "Single dependency (array format)", - frontmatter: map[string]any{ - "dependencies": []any{"microsoft/apm-sample-package"}, - }, - expectedDeps: []string{"microsoft/apm-sample-package"}, - }, - { - name: "Multiple dependencies (array format)", - frontmatter: map[string]any{ - "dependencies": []any{ - "microsoft/apm-sample-package", - "github/awesome-copilot/skills/review-and-refactor", - "anthropics/skills/skills/frontend-design", - }, - }, - expectedDeps: []string{ - "microsoft/apm-sample-package", - "github/awesome-copilot/skills/review-and-refactor", - "anthropics/skills/skills/frontend-design", - }, - }, - { - name: "Empty array", - frontmatter: map[string]any{ - "dependencies": []any{}, - }, - expectedDeps: nil, - }, - { - name: "Non-array, non-object value is ignored", - frontmatter: map[string]any{ - "dependencies": "microsoft/apm-sample-package", - }, - expectedDeps: nil, - }, - { - name: "Empty string items are skipped", - frontmatter: map[string]any{ - "dependencies": []any{"microsoft/apm-sample-package", "", "github/awesome-copilot"}, - }, - 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 { - t.Run(tt.name, func(t *testing.T) { - result, err := extractAPMDependenciesFromFrontmatter(tt.frontmatter) - require.NoError(t, err, "Should not return an error for valid frontmatter") - if tt.expectedDeps == nil { - assert.Nil(t, result, "Should return nil for no dependencies") - } 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 TestExtractAPMDependenciesGitHubApp(t *testing.T) { - t.Run("Object format with github-app", func(t *testing.T) { - frontmatter := map[string]any{ - "dependencies": map[string]any{ - "packages": []any{"acme-org/acme-skills/plugins/dev-tools"}, - "github-app": map[string]any{ - "app-id": "${{ vars.APP_ID }}", - "private-key": "${{ secrets.APP_PRIVATE_KEY }}", - }, - }, - } - result, err := extractAPMDependenciesFromFrontmatter(frontmatter) - require.NoError(t, err, "Should not return an error") - require.NotNil(t, result, "Should return non-nil APMDependenciesInfo") - assert.Equal(t, []string{"acme-org/acme-skills/plugins/dev-tools"}, result.Packages) - require.NotNil(t, result.GitHubApp, "GitHubApp should be set") - assert.Equal(t, "${{ vars.APP_ID }}", result.GitHubApp.AppID) - assert.Equal(t, "${{ secrets.APP_PRIVATE_KEY }}", result.GitHubApp.PrivateKey) - }) - - t.Run("Object format with github-app including owner and repositories", func(t *testing.T) { - frontmatter := map[string]any{ - "dependencies": map[string]any{ - "packages": []any{"acme-org/acme-skills/plugins/dev-tools"}, - "github-app": map[string]any{ - "app-id": "${{ vars.APP_ID }}", - "private-key": "${{ secrets.APP_PRIVATE_KEY }}", - "owner": "acme-org", - "repositories": []any{"acme-skills"}, - }, - }, - } - result, err := extractAPMDependenciesFromFrontmatter(frontmatter) - require.NoError(t, err, "Should not return an error") - require.NotNil(t, result, "Should return non-nil APMDependenciesInfo") - require.NotNil(t, result.GitHubApp, "GitHubApp should be set") - assert.Equal(t, "acme-org", result.GitHubApp.Owner) - assert.Equal(t, []string{"acme-skills"}, result.GitHubApp.Repositories) - }) - - t.Run("Object format with github-app missing app-id is ignored", func(t *testing.T) { - frontmatter := map[string]any{ - "dependencies": map[string]any{ - "packages": []any{"acme-org/acme-skills"}, - "github-app": map[string]any{ - "private-key": "${{ secrets.APP_PRIVATE_KEY }}", - }, - }, - } - result, err := extractAPMDependenciesFromFrontmatter(frontmatter) - require.NoError(t, err, "Should not return an error") - require.NotNil(t, result, "Packages should still be extracted") - assert.Nil(t, result.GitHubApp, "GitHubApp should be nil when app-id is missing") - }) - - t.Run("Array format does not support github-app", func(t *testing.T) { - frontmatter := map[string]any{ - "dependencies": []any{"microsoft/apm-sample-package"}, - } - result, err := extractAPMDependenciesFromFrontmatter(frontmatter) - require.NoError(t, err, "Should not return an error") - require.NotNil(t, result, "Should return non-nil APMDependenciesInfo") - assert.Nil(t, result.GitHubApp, "GitHubApp should be nil for array format") - }) -} - -func TestExtractAPMDependenciesVersion(t *testing.T) { - t.Run("Object format with version field", func(t *testing.T) { - frontmatter := map[string]any{ - "dependencies": map[string]any{ - "packages": []any{"microsoft/apm-sample-package"}, - "version": "v1.0.0", - }, - } - result, err := extractAPMDependenciesFromFrontmatter(frontmatter) - require.NoError(t, err, "Should not return an error for valid version") - require.NotNil(t, result, "Should return non-nil APMDependenciesInfo") - assert.Equal(t, "v1.0.0", result.Version, "Version should be extracted from object format") - }) - - t.Run("Array format has no version field", func(t *testing.T) { - frontmatter := map[string]any{ - "dependencies": []any{"microsoft/apm-sample-package"}, - } - result, err := extractAPMDependenciesFromFrontmatter(frontmatter) - require.NoError(t, err, "Should not return an error") - require.NotNil(t, result, "Should return non-nil APMDependenciesInfo") - assert.Empty(t, result.Version, "Version should be empty for array format") - }) - - t.Run("Object format without version uses empty string", func(t *testing.T) { - frontmatter := map[string]any{ - "dependencies": map[string]any{ - "packages": []any{"microsoft/apm-sample-package"}, - }, - } - result, err := extractAPMDependenciesFromFrontmatter(frontmatter) - require.NoError(t, err, "Should not return an error") - require.NotNil(t, result, "Should return non-nil APMDependenciesInfo") - assert.Empty(t, result.Version, "Version should be empty when not specified") - }) - - t.Run("Invalid version with trailing quote produces error", func(t *testing.T) { - frontmatter := map[string]any{ - "dependencies": map[string]any{ - "packages": []any{"microsoft/apm-sample-package"}, - "version": `v0.8.0"`, - }, - } - result, err := extractAPMDependenciesFromFrontmatter(frontmatter) - require.Error(t, err, "Should return an error for invalid version tag") - assert.Nil(t, result, "Should return nil result on error") - assert.Contains(t, err.Error(), "dependencies.version", "Error should mention the field name") - }) - - t.Run("Invalid version without v prefix produces error", func(t *testing.T) { - frontmatter := map[string]any{ - "dependencies": map[string]any{ - "packages": []any{"microsoft/apm-sample-package"}, - "version": "1.2.3", - }, - } - result, err := extractAPMDependenciesFromFrontmatter(frontmatter) - require.Error(t, err, "Should return an error for version missing v prefix") - assert.Nil(t, result, "Should return nil result on error") - assert.Contains(t, err.Error(), "vX.Y.Z", "Error should describe expected format") - }) - - t.Run("Invalid version string 'latest' produces error", func(t *testing.T) { - frontmatter := map[string]any{ - "dependencies": map[string]any{ - "packages": []any{"microsoft/apm-sample-package"}, - "version": "latest", - }, - } - result, err := extractAPMDependenciesFromFrontmatter(frontmatter) - require.Error(t, err, "Should return an error for non-semver version string") - assert.Nil(t, result, "Should return nil result on error") - }) - - t.Run("Valid partial version v1 compiles without error", func(t *testing.T) { - frontmatter := map[string]any{ - "dependencies": map[string]any{ - "packages": []any{"microsoft/apm-sample-package"}, - "version": "v1", - }, - } - result, err := extractAPMDependenciesFromFrontmatter(frontmatter) - require.NoError(t, err, "Should not return an error for valid partial version") - require.NotNil(t, result, "Should return non-nil APMDependenciesInfo") - assert.Equal(t, "v1", result.Version, "Version should be extracted") - }) - - t.Run("Valid partial version v1.2 compiles without error", func(t *testing.T) { - frontmatter := map[string]any{ - "dependencies": map[string]any{ - "packages": []any{"microsoft/apm-sample-package"}, - "version": "v1.2", - }, - } - result, err := extractAPMDependenciesFromFrontmatter(frontmatter) - require.NoError(t, err, "Should not return an error for valid partial version") - require.NotNil(t, result, "Should return non-nil APMDependenciesInfo") - assert.Equal(t, "v1.2", result.Version, "Version should be extracted") - }) -} - -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 with copilot target", - apmDeps: &APMDependenciesInfo{Packages: []string{"microsoft/apm-sample-package"}}, - target: "copilot", - expectedContains: []string{ - "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", - "apm-version: ${{ env.GH_AW_INFO_APM_VERSION }}", - }, - }, - { - 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", - "apm-version: ${{ env.GH_AW_INFO_APM_VERSION }}", - }, - }, - { - name: "All target for non-copilot/claude engine", - apmDeps: &APMDependenciesInfo{Packages: []string{"microsoft/apm-sample-package"}}, - target: "all", - expectedContains: []string{ - "target: all", - "apm-version: ${{ env.GH_AW_INFO_APM_VERSION }}", - }, - }, - { - name: "Custom APM version still uses env var reference in step", - apmDeps: &APMDependenciesInfo{Packages: []string{"microsoft/apm-sample-package"}, Version: "v1.0.0"}, - target: "copilot", - expectedContains: []string{ - "apm-version: ${{ env.GH_AW_INFO_APM_VERSION }}", - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - data := &WorkflowData{Name: "test-workflow"} - step := GenerateAPMPackStep(tt.apmDeps, tt.target, data) - - 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") - combined := combineStepLines(step) - - for _, expected := range tt.expectedContains { - assert.Contains(t, combined, expected, "Step should contain: %s", expected) - } - }) - } -} - -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", - "actions/github-script", - "APM_BUNDLE_DIR: /tmp/gh-aw/apm-bundle", - "apm_unpack.cjs", - "await main();", - }, - expectedNotContains: []string{"microsoft/apm-action"}, - }, - { - name: "Isolated restore step", - apmDeps: &APMDependenciesInfo{Packages: []string{"microsoft/apm-sample-package"}, Isolated: true}, - expectedContains: []string{ - "Restore APM dependencies", - "actions/github-script", - "APM_BUNDLE_DIR: /tmp/gh-aw/apm-bundle", - "apm_unpack.cjs", - "await main();", - }, - expectedNotContains: []string{"microsoft/apm-action"}, - }, - { - name: "Custom APM version still uses JS unpacker (version not needed for unpack)", - apmDeps: &APMDependenciesInfo{Packages: []string{"microsoft/apm-sample-package"}, Version: "v1.0.0"}, - expectedContains: []string{ - "apm_unpack.cjs", - "actions/github-script", - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - data := &WorkflowData{Name: "test-workflow"} - step := GenerateAPMRestoreStep(tt.apmDeps, data) - - 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") - combined := combineStepLines(step) - - 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) - } - }) - } -} - -func TestGenerateAPMPackStepWithGitHubApp(t *testing.T) { - t.Run("Pack step includes GITHUB_TOKEN env when github-app is configured", func(t *testing.T) { - apmDeps := &APMDependenciesInfo{ - Packages: []string{"acme-org/acme-skills/plugins/dev-tools"}, - GitHubApp: &GitHubAppConfig{ - AppID: "${{ vars.APP_ID }}", - PrivateKey: "${{ secrets.APP_PRIVATE_KEY }}", - }, - } - data := &WorkflowData{Name: "test-workflow"} - step := GenerateAPMPackStep(apmDeps, "claude", data) - - require.NotEmpty(t, step, "Step should not be empty") - combined := combineStepLines(step) - - assert.Contains(t, combined, "GITHUB_TOKEN: ${{ steps.apm-app-token.outputs.token }}", "Should inject app token as GITHUB_TOKEN") - assert.Contains(t, combined, "env:", "Should have env section") - assert.Contains(t, combined, "- acme-org/acme-skills/plugins/dev-tools", "Should list dependency") - }) - - t.Run("Pack step uses cascading fallback GITHUB_TOKEN without github-app", func(t *testing.T) { - apmDeps := &APMDependenciesInfo{ - Packages: []string{"microsoft/apm-sample-package"}, - } - data := &WorkflowData{Name: "test-workflow"} - step := GenerateAPMPackStep(apmDeps, "copilot", data) - - require.NotEmpty(t, step, "Step should not be empty") - combined := combineStepLines(step) - - assert.Contains(t, combined, "GITHUB_TOKEN:", "Should have GITHUB_TOKEN with cascading fallback") - assert.Contains(t, combined, "GH_AW_PLUGINS_TOKEN", "Should reference cascading token") - assert.Contains(t, combined, "GH_AW_GITHUB_TOKEN", "Should reference cascading token") - assert.NotContains(t, combined, "apm-app-token", "Should not reference app token without github-app") - }) -} - -func TestBuildAPMAppTokenMintStep(t *testing.T) { - t.Run("Basic app token mint step", func(t *testing.T) { - app := &GitHubAppConfig{ - AppID: "${{ vars.APP_ID }}", - PrivateKey: "${{ secrets.APP_PRIVATE_KEY }}", - } - steps := buildAPMAppTokenMintStep(app, "") - - combined := strings.Join(steps, "") - assert.Contains(t, combined, "Generate GitHub App token for APM dependencies", "Should have descriptive step name") - assert.Contains(t, combined, "id: apm-app-token", "Should use apm-app-token step ID") - assert.Contains(t, combined, "actions/create-github-app-token", "Should use create-github-app-token action") - assert.Contains(t, combined, "app-id: ${{ vars.APP_ID }}", "Should include app-id") - assert.Contains(t, combined, "private-key: ${{ secrets.APP_PRIVATE_KEY }}", "Should include private-key") - assert.Contains(t, combined, "github-api-url: ${{ github.api_url }}", "Should include github-api-url") - }) - - t.Run("App token mint step with explicit owner", func(t *testing.T) { - app := &GitHubAppConfig{ - AppID: "${{ vars.APP_ID }}", - PrivateKey: "${{ secrets.APP_PRIVATE_KEY }}", - Owner: "acme-org", - } - steps := buildAPMAppTokenMintStep(app, "") - - combined := strings.Join(steps, "") - assert.Contains(t, combined, "owner: acme-org", "Should include explicit owner") - }) - - t.Run("App token mint step defaults to github.repository_owner when owner not set", func(t *testing.T) { - app := &GitHubAppConfig{ - AppID: "${{ vars.APP_ID }}", - PrivateKey: "${{ secrets.APP_PRIVATE_KEY }}", - } - steps := buildAPMAppTokenMintStep(app, "") - - combined := strings.Join(steps, "") - assert.Contains(t, combined, "owner: ${{ github.repository_owner }}", "Should default owner to github.repository_owner") - }) - - t.Run("App token mint step with wildcard repositories omits repositories field", func(t *testing.T) { - app := &GitHubAppConfig{ - AppID: "${{ vars.APP_ID }}", - PrivateKey: "${{ secrets.APP_PRIVATE_KEY }}", - Repositories: []string{"*"}, - } - steps := buildAPMAppTokenMintStep(app, "") - - combined := strings.Join(steps, "") - assert.NotContains(t, combined, "repositories:", "Should omit repositories field for org-wide access") - }) - - t.Run("App token mint step with explicit repositories", func(t *testing.T) { - app := &GitHubAppConfig{ - AppID: "${{ vars.APP_ID }}", - PrivateKey: "${{ secrets.APP_PRIVATE_KEY }}", - Repositories: []string{"acme-skills"}, - } - steps := buildAPMAppTokenMintStep(app, "") - - combined := strings.Join(steps, "") - assert.Contains(t, combined, "repositories: acme-skills", "Should include explicit repository") - }) - - t.Run("App token mint step uses fallbackRepoExpr when no repositories configured", func(t *testing.T) { - app := &GitHubAppConfig{ - AppID: "${{ vars.APP_ID }}", - PrivateKey: "${{ secrets.APP_PRIVATE_KEY }}", - } - steps := buildAPMAppTokenMintStep(app, "${{ steps.resolve-host-repo.outputs.target_repo_name }}") - - combined := strings.Join(steps, "") - assert.Contains(t, combined, "repositories: ${{ steps.resolve-host-repo.outputs.target_repo_name }}", "Should use fallback repo expr for workflow_call relay") - assert.NotContains(t, combined, "github.event.repository.name", "Should not fall back to event repository") - }) - - t.Run("App token mint step defaults to github.event.repository.name when no fallback and no repositories", func(t *testing.T) { - app := &GitHubAppConfig{ - AppID: "${{ vars.APP_ID }}", - PrivateKey: "${{ secrets.APP_PRIVATE_KEY }}", - } - steps := buildAPMAppTokenMintStep(app, "") - - combined := strings.Join(steps, "") - assert.Contains(t, combined, "repositories: ${{ github.event.repository.name }}", "Should default to event repository name") - }) -} - -func TestBuildAPMAppTokenInvalidationStep(t *testing.T) { - t.Run("Invalidation step targets apm-app-token step ID", func(t *testing.T) { - steps := buildAPMAppTokenInvalidationStep() - - combined := strings.Join(steps, "") - assert.Contains(t, combined, "Invalidate GitHub App token for APM", "Should have descriptive step name") - assert.Contains(t, combined, fmt.Sprintf("if: always() && steps.%s.outputs.token != ''", apmAppTokenStepID), "Should run always and check token exists") - assert.Contains(t, combined, fmt.Sprintf("TOKEN: ${{ steps.%s.outputs.token }}", apmAppTokenStepID), "Should reference apm-app-token step output") - assert.Contains(t, combined, "gh api", "Should call GitHub API to revoke token") - assert.Contains(t, combined, "--method DELETE", "Should use DELETE method to revoke token") - assert.Contains(t, combined, "/installation/token", "Should target installation token endpoint") - }) - - t.Run("Invalidation step uses always() condition for cleanup even on failure", func(t *testing.T) { - steps := buildAPMAppTokenInvalidationStep() - - combined := strings.Join(steps, "") - assert.Contains(t, combined, "always()", "Must run even if prior steps fail to ensure token cleanup") - }) -} - -func TestExtractAPMDependenciesEnv(t *testing.T) { - t.Run("Object format with env field extracts env vars", func(t *testing.T) { - frontmatter := map[string]any{ - "dependencies": map[string]any{ - "packages": []any{"microsoft/apm-sample-package"}, - "env": map[string]any{ - "MY_TOKEN": "${{ secrets.MY_TOKEN }}", - "REGISTRY": "https://registry.example.com", - }, - }, - } - result, err := extractAPMDependenciesFromFrontmatter(frontmatter) - require.NoError(t, err, "Should not return an error") - require.NotNil(t, result, "Should return non-nil APMDependenciesInfo") - require.NotNil(t, result.Env, "Env map should be set") - assert.Equal(t, "${{ secrets.MY_TOKEN }}", result.Env["MY_TOKEN"], "MY_TOKEN should be extracted") - assert.Equal(t, "https://registry.example.com", result.Env["REGISTRY"], "REGISTRY should be extracted") - }) - - t.Run("Object format without env field has nil Env", func(t *testing.T) { - frontmatter := map[string]any{ - "dependencies": map[string]any{ - "packages": []any{"microsoft/apm-sample-package"}, - }, - } - result, err := extractAPMDependenciesFromFrontmatter(frontmatter) - require.NoError(t, err, "Should not return an error") - require.NotNil(t, result, "Should return non-nil APMDependenciesInfo") - assert.Nil(t, result.Env, "Env should be nil when not specified") - }) - - t.Run("Array format has nil Env", func(t *testing.T) { - frontmatter := map[string]any{ - "dependencies": []any{"microsoft/apm-sample-package"}, - } - result, err := extractAPMDependenciesFromFrontmatter(frontmatter) - require.NoError(t, err, "Should not return an error") - require.NotNil(t, result, "Should return non-nil APMDependenciesInfo") - assert.Nil(t, result.Env, "Env should be nil for array format") - }) - - t.Run("Non-string env values are skipped", func(t *testing.T) { - frontmatter := map[string]any{ - "dependencies": map[string]any{ - "packages": []any{"microsoft/apm-sample-package"}, - "env": map[string]any{ - "VALID": "value", - "INVALID": 42, - }, - }, - } - result, err := extractAPMDependenciesFromFrontmatter(frontmatter) - require.NoError(t, err, "Should not return an error") - require.NotNil(t, result, "Should return non-nil APMDependenciesInfo") - require.NotNil(t, result.Env, "Env map should be set") - assert.Equal(t, "value", result.Env["VALID"], "String value should be extracted") - assert.NotContains(t, result.Env, "INVALID", "Non-string value should be skipped") - }) -} - -func TestGenerateAPMPackStepWithEnv(t *testing.T) { - t.Run("Pack step includes user env vars and cascading GITHUB_TOKEN", func(t *testing.T) { - apmDeps := &APMDependenciesInfo{ - Packages: []string{"microsoft/apm-sample-package"}, - Env: map[string]string{ - "MY_TOKEN": "${{ secrets.MY_TOKEN }}", - "REGISTRY": "https://registry.example.com", - }, - } - data := &WorkflowData{Name: "test-workflow"} - step := GenerateAPMPackStep(apmDeps, "copilot", data) - - require.NotEmpty(t, step, "Step should not be empty") - combined := combineStepLines(step) - - assert.Contains(t, combined, "env:", "Should have env section") - assert.Contains(t, combined, "MY_TOKEN: ${{ secrets.MY_TOKEN }}", "Should include MY_TOKEN env var") - assert.Contains(t, combined, "REGISTRY: https://registry.example.com", "Should include REGISTRY env var") - assert.Contains(t, combined, "GITHUB_TOKEN:", "Should have GITHUB_TOKEN with cascading fallback") - assert.Contains(t, combined, "GH_AW_PLUGINS_TOKEN", "Cascading fallback should include GH_AW_PLUGINS_TOKEN") - }) - - t.Run("Pack step with env vars and github-app includes both", func(t *testing.T) { - apmDeps := &APMDependenciesInfo{ - Packages: []string{"acme-org/acme-skills"}, - GitHubApp: &GitHubAppConfig{ - AppID: "${{ vars.APP_ID }}", - PrivateKey: "${{ secrets.APP_PRIVATE_KEY }}", - }, - Env: map[string]string{ - "EXTRA": "value", - }, - } - data := &WorkflowData{Name: "test-workflow"} - step := GenerateAPMPackStep(apmDeps, "copilot", data) - - require.NotEmpty(t, step, "Step should not be empty") - combined := combineStepLines(step) - - assert.Contains(t, combined, "GITHUB_TOKEN: ${{ steps.apm-app-token.outputs.token }}", "Should have GITHUB_TOKEN from app") - assert.Contains(t, combined, "EXTRA: value", "Should include user env var") - }) - - t.Run("Env vars are output in sorted order for determinism", func(t *testing.T) { - apmDeps := &APMDependenciesInfo{ - Packages: []string{"microsoft/apm-sample-package"}, - Env: map[string]string{ - "Z_VAR": "z", - "A_VAR": "a", - "M_VAR": "m", - }, - } - data := &WorkflowData{Name: "test-workflow"} - step := GenerateAPMPackStep(apmDeps, "copilot", data) - - require.NotEmpty(t, step, "Step should not be empty") - combined := combineStepLines(step) - - aPos := strings.Index(combined, "A_VAR:") - mPos := strings.Index(combined, "M_VAR:") - zPos := strings.Index(combined, "Z_VAR:") - require.NotEqual(t, -1, aPos, "A_VAR should be present in output") - require.NotEqual(t, -1, mPos, "M_VAR should be present in output") - require.NotEqual(t, -1, zPos, "Z_VAR should be present in output") - assert.True(t, aPos < mPos && mPos < zPos, "Env vars should be sorted alphabetically") - }) - - t.Run("GITHUB_TOKEN in user env is skipped when github-app is configured", func(t *testing.T) { - apmDeps := &APMDependenciesInfo{ - Packages: []string{"acme-org/acme-skills"}, - GitHubApp: &GitHubAppConfig{ - AppID: "${{ vars.APP_ID }}", - PrivateKey: "${{ secrets.APP_PRIVATE_KEY }}", - }, - Env: map[string]string{ - "GITHUB_TOKEN": "should-be-skipped", - "OTHER_VAR": "kept", - }, - } - data := &WorkflowData{Name: "test-workflow"} - step := GenerateAPMPackStep(apmDeps, "copilot", data) - - require.NotEmpty(t, step, "Step should not be empty") - combined := combineStepLines(step) - - assert.Contains(t, combined, "GITHUB_TOKEN: ${{ steps.apm-app-token.outputs.token }}", "Should have GITHUB_TOKEN from app token, not user env") - assert.NotContains(t, combined, "should-be-skipped", "User-supplied GITHUB_TOKEN value should be absent") - assert.Contains(t, combined, "OTHER_VAR: kept", "Other user env vars should be present") - count := strings.Count(combined, "GITHUB_TOKEN:") - assert.Equal(t, 1, count, "GITHUB_TOKEN should appear exactly once") - }) -} - -func TestExtractAPMDependenciesGitHubToken(t *testing.T) { - t.Run("Object format with github-token extracts token", func(t *testing.T) { - frontmatter := map[string]any{ - "dependencies": map[string]any{ - "packages": []any{"microsoft/apm-sample-package"}, - "github-token": "${{ secrets.MY_TOKEN }}", - }, - } - result, err := extractAPMDependenciesFromFrontmatter(frontmatter) - require.NoError(t, err, "Should not return an error") - require.NotNil(t, result, "Should return non-nil APMDependenciesInfo") - assert.Equal(t, "${{ secrets.MY_TOKEN }}", result.GitHubToken, "Should extract github-token") - }) - - t.Run("Object format without github-token has empty GitHubToken", func(t *testing.T) { - frontmatter := map[string]any{ - "dependencies": map[string]any{ - "packages": []any{"microsoft/apm-sample-package"}, - }, - } - result, err := extractAPMDependenciesFromFrontmatter(frontmatter) - require.NoError(t, err, "Should not return an error") - require.NotNil(t, result, "Should return non-nil APMDependenciesInfo") - assert.Empty(t, result.GitHubToken, "GitHubToken should be empty when not specified") - }) - - t.Run("Array format has empty GitHubToken", func(t *testing.T) { - frontmatter := map[string]any{ - "dependencies": []any{"microsoft/apm-sample-package"}, - } - result, err := extractAPMDependenciesFromFrontmatter(frontmatter) - require.NoError(t, err, "Should not return an error") - require.NotNil(t, result, "Should return non-nil APMDependenciesInfo") - assert.Empty(t, result.GitHubToken, "GitHubToken should be empty for array format") - }) -} - -func TestGetEffectiveAPMGitHubToken(t *testing.T) { - t.Run("Custom token is used as-is", func(t *testing.T) { - result := getEffectiveAPMGitHubToken("${{ secrets.MY_CUSTOM_TOKEN }}") - assert.Equal(t, "${{ secrets.MY_CUSTOM_TOKEN }}", result, "Custom token should be returned unchanged") - }) - - t.Run("Empty token returns cascading fallback", func(t *testing.T) { - result := getEffectiveAPMGitHubToken("") - assert.Contains(t, result, "GH_AW_PLUGINS_TOKEN", "Fallback should include GH_AW_PLUGINS_TOKEN") - assert.Contains(t, result, "GH_AW_GITHUB_TOKEN", "Fallback should include GH_AW_GITHUB_TOKEN") - assert.Contains(t, result, "GITHUB_TOKEN", "Fallback should include GITHUB_TOKEN") - }) -} - -func TestGenerateAPMPackStepWithGitHubToken(t *testing.T) { - t.Run("Pack step uses custom github-token when specified", func(t *testing.T) { - apmDeps := &APMDependenciesInfo{ - Packages: []string{"microsoft/apm-sample-package"}, - GitHubToken: "${{ secrets.MY_TOKEN }}", - } - data := &WorkflowData{Name: "test-workflow"} - step := GenerateAPMPackStep(apmDeps, "copilot", data) - - require.NotEmpty(t, step, "Step should not be empty") - combined := combineStepLines(step) - - assert.Contains(t, combined, "GITHUB_TOKEN: ${{ secrets.MY_TOKEN }}", "Should use custom token directly") - assert.NotContains(t, combined, "apm-app-token", "Should not reference app token") - }) - - t.Run("Pack step uses cascading fallback when no github-token specified", func(t *testing.T) { - apmDeps := &APMDependenciesInfo{ - Packages: []string{"microsoft/apm-sample-package"}, - } - data := &WorkflowData{Name: "test-workflow"} - step := GenerateAPMPackStep(apmDeps, "copilot", data) - - require.NotEmpty(t, step, "Step should not be empty") - combined := combineStepLines(step) - - assert.Contains(t, combined, "GITHUB_TOKEN:", "Should have GITHUB_TOKEN") - assert.Contains(t, combined, "GH_AW_PLUGINS_TOKEN", "Should include GH_AW_PLUGINS_TOKEN in cascade") - }) - - t.Run("github-app takes priority over github-token", func(t *testing.T) { - apmDeps := &APMDependenciesInfo{ - Packages: []string{"microsoft/apm-sample-package"}, - GitHubToken: "${{ secrets.MY_TOKEN }}", - GitHubApp: &GitHubAppConfig{ - AppID: "${{ vars.APP_ID }}", - PrivateKey: "${{ secrets.APP_PRIVATE_KEY }}", - }, - } - data := &WorkflowData{Name: "test-workflow"} - step := GenerateAPMPackStep(apmDeps, "copilot", data) - - require.NotEmpty(t, step, "Step should not be empty") - combined := combineStepLines(step) - - assert.Contains(t, combined, "GITHUB_TOKEN: ${{ steps.apm-app-token.outputs.token }}", "github-app token should take priority") - assert.NotContains(t, combined, "secrets.MY_TOKEN", "Custom github-token should not appear when github-app is configured") - }) -} - -func TestExtractAPMDependenciesFromImportsAPMPackages(t *testing.T) { - t.Run("Simple array form under imports.apm-packages", func(t *testing.T) { - frontmatter := map[string]any{ - "imports": map[string]any{ - "apm-packages": []any{ - "microsoft/apm-sample-package", - "github/awesome-copilot/skills/review-and-refactor", - }, - }, - } - result, err := extractAPMDependenciesFromFrontmatter(frontmatter) - require.NoError(t, err, "Should not return an error") - require.NotNil(t, result, "Should return non-nil APMDependenciesInfo") - assert.Equal(t, []string{ - "microsoft/apm-sample-package", - "github/awesome-copilot/skills/review-and-refactor", - }, result.Packages, "Should extract packages from imports.apm-packages") - }) - - t.Run("Object form under imports.apm-packages with isolated", func(t *testing.T) { - frontmatter := map[string]any{ - "imports": map[string]any{ - "apm-packages": map[string]any{ - "packages": []any{"microsoft/apm-sample-package"}, - "isolated": true, - }, - }, - } - result, err := extractAPMDependenciesFromFrontmatter(frontmatter) - require.NoError(t, err, "Should not return an error") - require.NotNil(t, result, "Should return non-nil APMDependenciesInfo") - assert.Equal(t, []string{"microsoft/apm-sample-package"}, result.Packages, "Should extract packages") - assert.True(t, result.Isolated, "Should extract isolated flag") - }) - - t.Run("Object form under imports.apm-packages with github-app", func(t *testing.T) { - frontmatter := map[string]any{ - "imports": map[string]any{ - "apm-packages": map[string]any{ - "packages": []any{"acme-org/acme-skills/plugins/dev-tools"}, - "github-app": map[string]any{ - "app-id": "${{ vars.APP_ID }}", - "private-key": "${{ secrets.APP_PRIVATE_KEY }}", - }, - }, - }, - } - result, err := extractAPMDependenciesFromFrontmatter(frontmatter) - require.NoError(t, err, "Should not return an error") - require.NotNil(t, result, "Should return non-nil APMDependenciesInfo") - assert.Equal(t, []string{"acme-org/acme-skills/plugins/dev-tools"}, result.Packages) - require.NotNil(t, result.GitHubApp, "GitHubApp should be set") - assert.Equal(t, "${{ vars.APP_ID }}", result.GitHubApp.AppID) - assert.Equal(t, "${{ secrets.APP_PRIVATE_KEY }}", result.GitHubApp.PrivateKey) - }) - - t.Run("mixing imports.apm-packages and top-level dependencies is an error", func(t *testing.T) { - frontmatter := map[string]any{ - "imports": map[string]any{ - "apm-packages": []any{"microsoft/from-imports"}, - }, - "dependencies": []any{"microsoft/from-dependencies"}, - } - result, err := extractAPMDependenciesFromFrontmatter(frontmatter) - require.Error(t, err, "Should return an error when both imports.apm-packages and dependencies are present") - assert.Nil(t, result, "Should return nil result on error") - assert.Contains(t, err.Error(), "imports.apm-packages", "Error should mention imports.apm-packages") - assert.Contains(t, err.Error(), "dependencies", "Error should mention dependencies") - }) - - t.Run("imports.apm-packages and aw subfield coexist", func(t *testing.T) { - frontmatter := map[string]any{ - "imports": map[string]any{ - "aw": []any{"shared/common-tools.md"}, - "apm-packages": []any{"microsoft/apm-sample-package"}, - }, - } - result, err := extractAPMDependenciesFromFrontmatter(frontmatter) - require.NoError(t, err, "Should not return an error") - require.NotNil(t, result, "Should return non-nil APMDependenciesInfo") - assert.Equal(t, []string{"microsoft/apm-sample-package"}, result.Packages) - }) - - t.Run("No imports.apm-packages and no dependencies returns nil", func(t *testing.T) { - frontmatter := map[string]any{ - "imports": map[string]any{ - "aw": []any{"shared/common-tools.md"}, - }, - } - result, err := extractAPMDependenciesFromFrontmatter(frontmatter) - require.NoError(t, err, "Should not return an error") - assert.Nil(t, result, "Should return nil when neither imports.apm-packages nor dependencies is present") - }) -} diff --git a/pkg/workflow/aw_info_versions_test.go b/pkg/workflow/aw_info_versions_test.go index 7203ac058fd..5f25b082a22 100644 --- a/pkg/workflow/aw_info_versions_test.go +++ b/pkg/workflow/aw_info_versions_test.go @@ -382,64 +382,3 @@ func TestAllVersionsInAwInfo(t *testing.T) { t.Errorf("Expected output to contain awmg_version '%s', got:\n%s", expectedAwmgLine, output) } } - -func TestApmVersionInAwInfo(t *testing.T) { - tests := []struct { - name string - apmDeps *APMDependenciesInfo - expectedApmVersion string - description string - }{ - { - name: "APM deps with explicit version", - apmDeps: &APMDependenciesInfo{Packages: []string{"microsoft/apm-sample-package"}, Version: "v1.0.0"}, - expectedApmVersion: "v1.0.0", - description: "Should use explicit APM version when provided", - }, - { - name: "APM deps with default version", - apmDeps: &APMDependenciesInfo{Packages: []string{"microsoft/apm-sample-package"}}, - expectedApmVersion: string(constants.DefaultAPMVersion), - description: "Should use default APM version when not specified", - }, - { - name: "No APM deps configured", - apmDeps: nil, - expectedApmVersion: "", - description: "Should not emit GH_AW_INFO_APM_VERSION when no APM dependencies are configured", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - compiler := NewCompilerWithVersion("1.0.0") - registry := GetGlobalEngineRegistry() - engine, err := registry.GetEngine("copilot") - if err != nil { - t.Fatalf("Failed to get copilot engine: %v", err) - } - - workflowData := &WorkflowData{ - Name: "Test Workflow", - APMDependencies: tt.apmDeps, - } - - var yaml strings.Builder - compiler.generateCreateAwInfo(&yaml, workflowData, engine) - output := yaml.String() - - if tt.expectedApmVersion == "" { - if strings.Contains(output, "GH_AW_INFO_APM_VERSION") { - t.Errorf("%s: Expected output to NOT contain GH_AW_INFO_APM_VERSION, got:\n%s", - tt.description, output) - } - } else { - expectedLine := `GH_AW_INFO_APM_VERSION: "` + tt.expectedApmVersion + `"` - if !strings.Contains(output, expectedLine) { - t.Errorf("%s: Expected output to contain '%s', got:\n%s", - tt.description, expectedLine, output) - } - } - }) - } -} diff --git a/pkg/workflow/compiler.go b/pkg/workflow/compiler.go index 109cc362698..ecf8f25a9c0 100644 --- a/pkg/workflow/compiler.go +++ b/pkg/workflow/compiler.go @@ -292,12 +292,6 @@ func (c *Compiler) validateWorkflowData(workflowData *WorkflowData, markdownPath c.IncrementWarningCount() } - // Emit experimental warning for dependencies (APM) feature - if workflowData.APMDependencies != nil && len(workflowData.APMDependencies.Packages) > 0 { - fmt.Fprintln(os.Stderr, console.FormatWarningMessage("Using experimental feature: dependencies (APM)")) - c.IncrementWarningCount() - } - // Emit experimental warning for rate-limit feature if workflowData.RateLimit != nil { fmt.Fprintln(os.Stderr, console.FormatWarningMessage("Using experimental feature: rate-limit")) diff --git a/pkg/workflow/compiler_activation_jobs_test.go b/pkg/workflow/compiler_activation_jobs_test.go index c965d492608..13889a1b450 100644 --- a/pkg/workflow/compiler_activation_jobs_test.go +++ b/pkg/workflow/compiler_activation_jobs_test.go @@ -502,59 +502,3 @@ func TestBuildMainJob_EngineSpecific(t *testing.T) { }) } } - -// TestBuildAPMJob_TokenInvalidation tests that the APM GitHub App token is invalidated after use -func TestBuildAPMJob_TokenInvalidation(t *testing.T) { - compiler := NewCompiler() - - t.Run("Invalidation step added when APM github-app is configured", func(t *testing.T) { - workflowData := &WorkflowData{ - Name: "Test Workflow", - Command: []string{"test"}, - APMDependencies: &APMDependenciesInfo{ - Packages: []string{"microsoft/apm-sample-package"}, - GitHubApp: &GitHubAppConfig{ - AppID: "${{ vars.APP_ID }}", - PrivateKey: "${{ secrets.APP_PRIVATE_KEY }}", - }, - }, - } - - job, err := compiler.buildAPMJob(workflowData) - require.NoError(t, err, "buildAPMJob should succeed") - require.NotNil(t, job) - - stepsStr := strings.Join(job.Steps, "") - - // Token mint step should be present - assert.Contains(t, stepsStr, "id: apm-app-token", "Should mint APM GitHub App token") - - // Invalidation step should be present and use always() condition - assert.Contains(t, stepsStr, "Invalidate GitHub App token for APM", "Should have APM token invalidation step") - assert.Contains(t, stepsStr, "always() && steps.apm-app-token.outputs.token != ''", "Invalidation step should run always") - - // Invalidation step should appear after the APM bundle upload - uploadIdx := strings.Index(stepsStr, "Upload APM bundle artifact") - invalidateIdx := strings.Index(stepsStr, "Invalidate GitHub App token for APM") - require.NotEqual(t, -1, uploadIdx, "Upload APM bundle artifact step must be present") - require.NotEqual(t, -1, invalidateIdx, "Invalidate GitHub App token for APM step must be present") - assert.Greater(t, invalidateIdx, uploadIdx, "Invalidation step should appear after APM bundle upload") - }) - - t.Run("No invalidation step when APM has no github-app", func(t *testing.T) { - workflowData := &WorkflowData{ - Name: "Test Workflow", - Command: []string{"test"}, - APMDependencies: &APMDependenciesInfo{ - Packages: []string{"microsoft/apm-sample-package"}, - }, - } - - job, err := compiler.buildAPMJob(workflowData) - require.NoError(t, err, "buildAPMJob should succeed") - require.NotNil(t, job) - - stepsStr := strings.Join(job.Steps, "") - assert.NotContains(t, stepsStr, "Invalidate GitHub App token for APM", "Should not have invalidation step when no github-app configured") - }) -} diff --git a/pkg/workflow/compiler_apm_job.go b/pkg/workflow/compiler_apm_job.go deleted file mode 100644 index 5fb4b853436..00000000000 --- a/pkg/workflow/compiler_apm_job.go +++ /dev/null @@ -1,104 +0,0 @@ -package workflow - -import ( - "fmt" - - "github.com/github/gh-aw/pkg/constants" - "github.com/github/gh-aw/pkg/logger" -) - -var compilerAPMJobLog = logger.New("workflow:compiler_apm_job") - -// buildAPMJob creates a dedicated job that installs and packs APM (Agent Package Manager) -// dependencies into a bundle artifact. This job runs after the activation job and uploads -// the packed bundle so the agent job can download and restore it. -// -// The APM job uses minimal permissions ({}) because all required tokens are passed -// explicitly via env/secrets rather than relying on the workflow's GITHUB_TOKEN scope. -func (c *Compiler) buildAPMJob(data *WorkflowData) (*Job, error) { - compilerAPMJobLog.Printf("Building APM job: %d packages", len(data.APMDependencies.Packages)) - - engine, err := c.getAgenticEngine(data.AI) - if err != nil { - return nil, fmt.Errorf("failed to get agentic engine for APM job: %w", err) - } - - var steps []string - - // Mint a GitHub App token before the pack step if a github-app is configured for APM. - // The APM job depends on activation, so it can reference needs.activation.outputs.target_repo_name - // instead of the activation-job-local steps.resolve-host-repo.outputs.target_repo_name. - if data.APMDependencies.GitHubApp != nil { - compilerAPMJobLog.Print("Adding APM GitHub App token mint step for cross-org access") - var apmFallbackRepoExpr string - if hasWorkflowCallTrigger(data.On) && !data.InlinedImports { - apmFallbackRepoExpr = "${{ needs.activation.outputs.target_repo_name }}" - } - steps = append(steps, buildAPMAppTokenMintStep(data.APMDependencies.GitHubApp, apmFallbackRepoExpr)...) - } - - // Add the APM pack step. - compilerAPMJobLog.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. - // The APM job depends on activation, so it uses artifactPrefixExprForDownstreamJob. - compilerAPMJobLog.Print("Adding APM bundle artifact upload step") - apmArtifactName := artifactPrefixExprForDownstreamJob(data) + constants.APMArtifactName - 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, " retention-days: 1\n") - - // Invalidate the APM GitHub App token after use to enforce least-privilege token lifecycle. - if data.APMDependencies.GitHubApp != nil { - compilerAPMJobLog.Print("Adding APM GitHub App token invalidation step") - steps = append(steps, buildAPMAppTokenInvalidationStep()...) - } - - // Set job-level GH_AW_INFO_APM_VERSION so the apm_pack step can reference it - // via ${{ env.GH_AW_INFO_APM_VERSION }} in its with: block. - apmVersion := data.APMDependencies.Version - if apmVersion == "" { - apmVersion = string(constants.DefaultAPMVersion) - } - env := map[string]string{ - "GH_AW_INFO_APM_VERSION": apmVersion, - } - - // Minimal permissions: the APM job does not need any GitHub token scopes because - // all tokens (for apm-action, create-github-app-token, upload-artifact) are either - // passed explicitly via secrets/env or handled by the runner's ACTIONS_RUNTIME_TOKEN. - permissions := NewPermissionsEmpty().RenderToYAML() - - job := &Job{ - Name: string(constants.APMJobName), - RunsOn: c.formatFrameworkJobRunsOn(data), - Permissions: c.indentYAMLLines(permissions, " "), - Env: env, - Steps: steps, - Needs: []string{string(constants.ActivationJobName)}, - } - - return job, nil -} - -// buildAPMJobWrapper builds the APM job and adds it to the job manager. -func (c *Compiler) buildAPMJobWrapper(data *WorkflowData) error { - apmJob, err := c.buildAPMJob(data) - if err != nil { - return fmt.Errorf("failed to build %s job: %w", constants.APMJobName, err) - } - c.jobManager.AddJob(apmJob) - compilerAPMJobLog.Printf("APM job added to job manager") - return nil -} diff --git a/pkg/workflow/compiler_jobs.go b/pkg/workflow/compiler_jobs.go index d4f2170a127..8a21343d3eb 100644 --- a/pkg/workflow/compiler_jobs.go +++ b/pkg/workflow/compiler_jobs.go @@ -221,15 +221,6 @@ func (c *Compiler) buildJobs(data *WorkflowData, markdownPath string) error { } } - // Build APM job if dependencies are specified. - // This separate job depends on activation, packs the APM bundle, and uploads it as - // an artifact. The agent job then depends on this APM job to download and restore it. - if data.APMDependencies != nil && len(data.APMDependencies.Packages) > 0 { - if err := c.buildAPMJobWrapper(data); err != nil { - return err - } - } - // Build main workflow job if err := c.buildMainJobWrapper(data, activationJobCreated); err != nil { return err diff --git a/pkg/workflow/compiler_main_job.go b/pkg/workflow/compiler_main_job.go index 1f179f3c024..1b5ddcac420 100644 --- a/pkg/workflow/compiler_main_job.go +++ b/pkg/workflow/compiler_main_job.go @@ -87,15 +87,6 @@ func (c *Compiler) buildMainJob(data *WorkflowData, activationJobCreated bool) ( compilerMainJobLog.Print("Agent job depends on indexing job (qmd tool configured)") } - // When APM dependencies are configured, the agent also depends on the APM job (which packs - // and uploads the bundle). The APM job depends on activation, but GitHub Actions only exposes - // outputs from DIRECT dependencies, so we must keep activation in needs too so that - // needs.activation.outputs.* expressions resolve correctly. - if data.APMDependencies != nil && len(data.APMDependencies.Packages) > 0 { - depends = append(depends, string(constants.APMJobName)) - compilerMainJobLog.Print("Agent job depends on APM job (APM dependencies configured)") - } - // Add custom jobs as dependencies only if they don't depend on pre_activation or agent // Custom jobs that depend on pre_activation are now dependencies of activation, // so the agent job gets them transitively through activation @@ -234,19 +225,6 @@ func (c *Compiler) buildMainJob(data *WorkflowData, activationJobCreated bool) ( env["GH_AW_WORKFLOW_ID_SANITIZED"] = sanitizedID } - // Set job-level GH_AW_INFO_APM_VERSION so the apm_restore step can reference it - // via ${{ env.GH_AW_INFO_APM_VERSION }} in its with: block - if data.APMDependencies != nil && len(data.APMDependencies.Packages) > 0 { - if env == nil { - env = make(map[string]string) - } - apmVersion := data.APMDependencies.Version - if apmVersion == "" { - apmVersion = string(constants.DefaultAPMVersion) - } - env["GH_AW_INFO_APM_VERSION"] = apmVersion - } - // Generate agent concurrency configuration agentConcurrency := GenerateJobConcurrencyConfig(data) diff --git a/pkg/workflow/compiler_orchestrator_tools.go b/pkg/workflow/compiler_orchestrator_tools.go index 5e6db849ad6..9682a4d4763 100644 --- a/pkg/workflow/compiler_orchestrator_tools.go +++ b/pkg/workflow/compiler_orchestrator_tools.go @@ -17,7 +17,6 @@ var orchestratorToolsLog = logger.New("workflow:compiler_orchestrator_tools") type toolsProcessingResult struct { tools map[string]any runtimes map[string]any - apmDependencies *APMDependenciesInfo // APM (Agent Package Manager) dependencies toolsTimeout int toolsStartupTimeout int markdownContent string @@ -156,18 +155,23 @@ func (c *Compiler) processToolsAndMarkdown(result *parser.FrontmatterResult, cle return nil, fmt.Errorf("failed to merge runtimes: %w", err) } - // Extract APM dependencies from frontmatter - apmDependencies, err := extractAPMDependenciesFromFrontmatter(result.Frontmatter) - if err != nil { - return nil, err - } - if apmDependencies != nil { - orchestratorToolsLog.Printf("Extracted %d APM dependencies from frontmatter", len(apmDependencies.Packages)) - } - // Add MCP fetch server if needed (when web-fetch is requested but engine doesn't support it) tools, _ = AddMCPFetchServerIfNeeded(tools, agenticEngine) + // Warn on deprecated APM configuration fields that are now ignored + if _, hasDependencies := result.Frontmatter["dependencies"]; hasDependencies { + fmt.Fprintln(os.Stderr, console.FormatWarningMessage("The 'dependencies' field is deprecated and no longer supported. Migrate to 'imports: - uses: shared/apm.md' to configure APM packages.")) + c.IncrementWarningCount() + } + if importsVal, hasImports := result.Frontmatter["imports"]; hasImports { + if importsMap, ok := importsVal.(map[string]any); ok { + if _, hasAPMPackages := importsMap["apm-packages"]; hasAPMPackages { + fmt.Fprintln(os.Stderr, console.FormatWarningMessage("The 'imports.apm-packages' field is deprecated and no longer supported. Migrate to 'imports: - uses: shared/apm.md' to configure APM packages.")) + c.IncrementWarningCount() + } + } + } + // Validate MCP configurations orchestratorToolsLog.Printf("Validating MCP configurations") if err := ValidateMCPConfigs(tools); err != nil { @@ -295,7 +299,6 @@ func (c *Compiler) processToolsAndMarkdown(result *parser.FrontmatterResult, cle return &toolsProcessingResult{ tools: tools, runtimes: runtimes, - apmDependencies: apmDependencies, toolsTimeout: toolsTimeout, toolsStartupTimeout: toolsStartupTimeout, markdownContent: markdownContent, diff --git a/pkg/workflow/compiler_orchestrator_workflow.go b/pkg/workflow/compiler_orchestrator_workflow.go index 9d2f6a62f63..31c26faaa30 100644 --- a/pkg/workflow/compiler_orchestrator_workflow.go +++ b/pkg/workflow/compiler_orchestrator_workflow.go @@ -186,7 +186,6 @@ func (c *Compiler) buildInitialWorkflowData( Tools: toolsResult.tools, ParsedTools: NewTools(toolsResult.tools), Runtimes: toolsResult.runtimes, - APMDependencies: toolsResult.apmDependencies, MarkdownContent: toolsResult.markdownContent, AI: engineSetup.engineSetting, EngineConfig: engineSetup.engineConfig, @@ -689,12 +688,6 @@ func applyTopLevelGitHubAppFallbacks(data *WorkflowData) { data.Tools["github"] = map[string]any{"github-app": appMap} } } - - // Fallback for APM dependencies (dependencies.github-app; no github-token field) - if data.APMDependencies != nil && topLevelFallbackNeeded(data.APMDependencies.GitHubApp, "") { - orchestratorWorkflowLog.Print("Applying top-level github-app fallback for dependencies") - data.APMDependencies.GitHubApp = fallback - } } // extractAdditionalConfigurations extracts cache-memory, repo-memory, mcp-scripts, and safe-outputs configurations diff --git a/pkg/workflow/compiler_types.go b/pkg/workflow/compiler_types.go index 3cdb27dde04..0e40301d939 100644 --- a/pkg/workflow/compiler_types.go +++ b/pkg/workflow/compiler_types.go @@ -416,7 +416,6 @@ type WorkflowData struct { RepoMemoryConfig *RepoMemoryConfig // parsed repo-memory configuration QmdConfig *QmdToolConfig // parsed qmd tool configuration (docs globs) Runtimes map[string]any // runtime version overrides from frontmatter - APMDependencies *APMDependenciesInfo // APM (Agent Package Manager) dependency packages to install ToolsTimeout int // timeout in seconds for tool/MCP operations (0 = use engine default) ToolsStartupTimeout int // timeout in seconds for MCP server startup (0 = use engine default) Features map[string]any // feature flags and configuration options from frontmatter (supports bool and string values) diff --git a/pkg/workflow/compiler_yaml.go b/pkg/workflow/compiler_yaml.go index f066b3676c9..d81ad684f19 100644 --- a/pkg/workflow/compiler_yaml.go +++ b/pkg/workflow/compiler_yaml.go @@ -644,15 +644,6 @@ func (c *Compiler) generateCreateAwInfo(yaml *strings.Builder, data *WorkflowDat mcpGatewayVersion = data.SandboxConfig.MCP.Version } - // APM version - apmVersion := "" - if data.APMDependencies != nil && len(data.APMDependencies.Packages) > 0 { - apmVersion = data.APMDependencies.Version - if apmVersion == "" { - apmVersion = string(constants.DefaultAPMVersion) - } - } - // Firewall type firewallType := "" if isFirewallEnabled(data) { @@ -690,9 +681,6 @@ func (c *Compiler) generateCreateAwInfo(yaml *strings.Builder, data *WorkflowDat fmt.Fprintf(yaml, " GH_AW_INFO_FIREWALL_ENABLED: \"%t\"\n", firewallEnabled) fmt.Fprintf(yaml, " GH_AW_INFO_AWF_VERSION: \"%s\"\n", firewallVersion) fmt.Fprintf(yaml, " GH_AW_INFO_AWMG_VERSION: \"%s\"\n", mcpGatewayVersion) - if apmVersion != "" { - fmt.Fprintf(yaml, " GH_AW_INFO_APM_VERSION: \"%s\"\n", apmVersion) - } fmt.Fprintf(yaml, " GH_AW_INFO_FIREWALL_TYPE: \"%s\"\n", firewallType) // Always include strict mode flag for lockdown validation. // validateLockdownRequirements uses this to enforce strict: true for public repositories. diff --git a/pkg/workflow/compiler_yaml_main_job.go b/pkg/workflow/compiler_yaml_main_job.go index 01657c081a5..71f6194c42d 100644 --- a/pkg/workflow/compiler_yaml_main_job.go +++ b/pkg/workflow/compiler_yaml_main_job.go @@ -261,26 +261,6 @@ 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 { - // 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") - fmt.Fprintf(yaml, " uses: %s\n", GetActionPin("actions/download-artifact")) - yaml.WriteString(" with:\n") - fmt.Fprintf(yaml, " name: %s\n", apmArtifactName) - 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") - } - } - // Restore qmd index and models cache if qmd tool is configured. // The index was built and cached in the indexing job; we restore it using the precise // cache key so we always get the index from the current workflow run. diff --git a/pkg/workflow/data/action_pins.json b/pkg/workflow/data/action_pins.json index e242badd291..dc29881145e 100644 --- a/pkg/workflow/data/action_pins.json +++ b/pkg/workflow/data/action_pins.json @@ -58,6 +58,11 @@ "version": "v3", "sha": "f8d387b68d61c58ab83c6c016672934102569859" }, + "actions/download-artifact@v4": { + "repo": "actions/download-artifact", + "version": "v4", + "sha": "d3f86a106a0bac45b974a628896c90dbdf5c8093" + }, "actions/download-artifact@v8.0.1": { "repo": "actions/download-artifact", "version": "v8.0.1", @@ -93,6 +98,11 @@ "version": "v6.2.0", "sha": "a309ff8b426b58ec0e2a45f0f869d46889d02405" }, + "actions/upload-artifact@v4": { + "repo": "actions/upload-artifact", + "version": "v4", + "sha": "ea165f8d65b6e75b540449e92b4886f43607fa02" + }, "actions/upload-artifact@v7": { "repo": "actions/upload-artifact", "version": "v7", @@ -163,6 +173,11 @@ "version": "v2.10.3", "sha": "9cd1b7bf3f36d5a3c3b17abc3545bfb5481912ea" }, + "microsoft/apm-action@v1.4.1": { + "repo": "microsoft/apm-action", + "version": "v1.4.1", + "sha": "a190b0b1a91031057144dc136acf9757a59c9e4d" + }, "oven-sh/setup-bun@v2.2.0": { "repo": "oven-sh/setup-bun", "version": "v2.2.0", @@ -177,11 +192,6 @@ "repo": "super-linter/super-linter", "version": "v8.5.0", "sha": "61abc07d755095a68f4987d1c2c3d1d64408f1f9" - }, - "microsoft/apm-action@v1.4.1": { - "repo": "microsoft/apm-action", - "version": "v1.4.1", - "sha": "a190b0b1a91031057144dc136acf9757a59c9e4d" } } } diff --git a/pkg/workflow/frontmatter_extraction_metadata.go b/pkg/workflow/frontmatter_extraction_metadata.go index b31570d5f06..338d5717abf 100644 --- a/pkg/workflow/frontmatter_extraction_metadata.go +++ b/pkg/workflow/frontmatter_extraction_metadata.go @@ -1,13 +1,10 @@ package workflow import ( - "errors" "fmt" "maps" - "os" "strings" - "github.com/github/gh-aw/pkg/console" "github.com/github/gh-aw/pkg/logger" ) @@ -241,140 +238,3 @@ func extractMCPServersFromFrontmatter(frontmatter map[string]any) map[string]any func extractRuntimesFromFrontmatter(frontmatter map[string]any) map[string]any { return ExtractMapField(frontmatter, "runtimes") } - -// extractAPMDependenciesFromFrontmatter extracts APM (Agent Package Manager) dependency -// configuration from frontmatter. Supports two sources: -// - imports.apm-packages (preferred location) -// - dependencies (deprecated; emits a deprecation warning when used alone) -// -// It is an error to specify both sources simultaneously. -// -// Each source supports: -// - Array format: ["org/pkg1", "org/pkg2"] -// - Object format: {packages: ["org/pkg1", "org/pkg2"], isolated: true, github-app: {...}, github-token: "...", version: "v0.8.0"} -// -// Returns nil if neither source is present or if the resolved source contains no packages. -func extractAPMDependenciesFromFrontmatter(frontmatter map[string]any) (*APMDependenciesInfo, error) { - hasImportsAPM := false - var importsAPMValue any - if importsAny, hasImports := frontmatter["imports"]; hasImports { - if importsMap, ok := importsAny.(map[string]any); ok { - if apmAny, hasAPM := importsMap["apm-packages"]; hasAPM { - hasImportsAPM = true - importsAPMValue = apmAny - } - } - } - - _, hasDependencies := frontmatter["dependencies"] - - // It is an error to specify both sources simultaneously. - if hasImportsAPM && hasDependencies { - return nil, errors.New( - "cannot use both 'imports.apm-packages' and 'dependencies' simultaneously; " + - "remove 'dependencies' and use 'imports.apm-packages' exclusively; " + - "run 'gh aw fix --write' to automatically migrate", - ) - } - - if hasImportsAPM { - frontmatterMetadataLog.Print("Extracting APM dependencies from imports.apm-packages") - return extractAPMDependenciesFromValue(importsAPMValue, "imports.apm-packages") - } - - // Fall back to top-level dependencies field (deprecated) - if !hasDependencies { - return nil, nil - } - - // Emit deprecation warning for the top-level dependencies field - fmt.Fprintln(os.Stderr, console.FormatWarningMessage( - "The top-level 'dependencies' field is deprecated. "+ - "Use 'imports.apm-packages' instead. "+ - "Run 'gh aw fix --write' to automatically migrate.", - )) - frontmatterMetadataLog.Print("Extracting APM dependencies from deprecated 'dependencies' field") - - return extractAPMDependenciesFromValue(frontmatter["dependencies"], "dependencies") -} - -// extractAPMDependenciesFromValue extracts APM dependency configuration from a frontmatter value. -// fieldName is used for error messages. -func extractAPMDependenciesFromValue(value any, fieldName string) (*APMDependenciesInfo, error) { - var packages []string - var isolated bool - var githubApp *GitHubAppConfig - var githubToken string - var version string - var env map[string]string - - switch v := value.(type) { - case []any: - // Array format: [pkg1, pkg2] - for _, item := range v { - if s, ok := item.(string); ok && s != "" { - packages = append(packages, s) - } - } - case map[string]any: - // Object format: {packages: [...], isolated: true, github-app: {...}, github-token: "...", version: "v0.8.0"} - 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 - } - } - if appAny, ok := v["github-app"]; ok { - if appMap, ok := appAny.(map[string]any); ok { - githubApp = parseAppConfig(appMap) - if githubApp.AppID == "" || githubApp.PrivateKey == "" { - frontmatterMetadataLog.Printf("%s.github-app missing required app-id or private-key; ignoring", fieldName) - githubApp = nil - } - } - } - if tokenAny, ok := v["github-token"]; ok { - if tokenStr, ok := tokenAny.(string); ok && tokenStr != "" { - githubToken = tokenStr - frontmatterMetadataLog.Printf("Extracted %s.github-token: custom token configured", fieldName) - } - } - if versionAny, ok := v["version"]; ok { - if versionStr, ok := versionAny.(string); ok && versionStr != "" { - if !isValidVersionTag(versionStr) { - return nil, fmt.Errorf("%s.version %q is not a valid semver tag (expected format: vX.Y.Z)", fieldName, versionStr) - } - version = versionStr - } - } - if envAny, ok := v["env"]; ok { - if envMap, ok := envAny.(map[string]any); ok && len(envMap) > 0 { - env = make(map[string]string, len(envMap)) - for k, val := range envMap { - if s, ok := val.(string); ok { - env[k] = s - } else { - frontmatterMetadataLog.Printf("Skipping non-string env value for key '%s'", k) - } - } - } - } - default: - return nil, nil - } - - if len(packages) == 0 { - return nil, nil - } - - frontmatterMetadataLog.Printf("Extracted %d APM dependency packages from %s (isolated=%v, github-app=%v, github-token=%v, version=%s, env=%d)", len(packages), fieldName, isolated, githubApp != nil, githubToken != "", version, len(env)) - return &APMDependenciesInfo{Packages: packages, Isolated: isolated, GitHubApp: githubApp, GitHubToken: githubToken, Version: version, Env: env}, nil -} diff --git a/pkg/workflow/frontmatter_types.go b/pkg/workflow/frontmatter_types.go index 37a1147e115..7f78cedf398 100644 --- a/pkg/workflow/frontmatter_types.go +++ b/pkg/workflow/frontmatter_types.go @@ -108,18 +108,6 @@ type PermissionsConfig struct { GitHubAppPermissionsConfig } -// APMDependenciesInfo encapsulates APM (Agent Package Manager) dependency configuration. -// Supports simple array format and object format with packages, isolated, github-app, and version 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 - GitHubApp *GitHubAppConfig // Optional GitHub App for cross-org private package access - GitHubToken string // Optional custom GitHub token expression (uses cascading fallback when empty) - Version string // Optional APM CLI version override (e.g., "v0.8.0"); defaults to DefaultAPMVersion - Env map[string]string // Optional environment variables to set on the APM pack step -} - // RateLimitConfig represents rate limiting configuration for workflow triggers // Limits how many times a user can trigger a workflow within a time window type RateLimitConfig struct { diff --git a/pkg/workflow/top_level_github_app_import_test.go b/pkg/workflow/top_level_github_app_import_test.go index c653ad1cd92..ed28d91147c 100644 --- a/pkg/workflow/top_level_github_app_import_test.go +++ b/pkg/workflow/top_level_github_app_import_test.go @@ -745,93 +745,3 @@ engine: copilot assert.Equal(t, "${{ vars.MCP_APP_ID }}", data.ParsedTools.GitHub.GitHubApp.AppID, "tools.github should use its own section-specific github-app, not the top-level fallback") } - -// TestTopLevelGitHubAppDependenciesFallback tests that the top-level github-app is applied -// to APM dependencies when no section-specific github-app is configured. -func TestTopLevelGitHubAppDependenciesFallback(t *testing.T) { - compiler := NewCompilerWithVersion("1.0.0") - - tmpDir := t.TempDir() - workflowsDir := filepath.Join(tmpDir, ".github", "workflows") - require.NoError(t, os.MkdirAll(workflowsDir, 0755)) - - workflowContent := `--- -on: issues -permissions: - contents: read -github-app: - app-id: ${{ vars.APP_ID }} - private-key: ${{ secrets.APP_PRIVATE_KEY }} -dependencies: - packages: - - myorg/private-skill -safe-outputs: - create-issue: -engine: copilot ---- - -# Top-level github-app fallback for APM dependencies. -` - mdPath := filepath.Join(workflowsDir, "main.md") - require.NoError(t, os.WriteFile(mdPath, []byte(workflowContent), 0644)) - - origDir, err := os.Getwd() - require.NoError(t, err) - require.NoError(t, os.Chdir(workflowsDir)) - defer func() { _ = os.Chdir(origDir) }() - - data, err := compiler.ParseWorkflowFile("main.md") - require.NoError(t, err) - - require.NotNil(t, data.APMDependencies, "APMDependencies should be populated") - require.NotNil(t, data.APMDependencies.GitHubApp, - "APMDependencies.GitHubApp should be populated from top-level fallback") - assert.Equal(t, "${{ vars.APP_ID }}", data.APMDependencies.GitHubApp.AppID, - "APM dependencies should use the top-level github-app fallback") -} - -// TestTopLevelGitHubAppDependenciesOverride tests that a section-specific dependencies.github-app -// takes precedence over the top-level github-app fallback. -func TestTopLevelGitHubAppDependenciesOverride(t *testing.T) { - compiler := NewCompilerWithVersion("1.0.0") - - tmpDir := t.TempDir() - workflowsDir := filepath.Join(tmpDir, ".github", "workflows") - require.NoError(t, os.MkdirAll(workflowsDir, 0755)) - - workflowContent := `--- -on: issues -permissions: - contents: read -github-app: - app-id: ${{ vars.TOP_LEVEL_APP_ID }} - private-key: ${{ secrets.TOP_LEVEL_APP_KEY }} -dependencies: - packages: - - myorg/private-skill - github-app: - app-id: ${{ vars.DEPS_APP_ID }} - private-key: ${{ secrets.DEPS_APP_KEY }} -safe-outputs: - create-issue: -engine: copilot ---- - -# dependencies.github-app overrides the top-level github-app fallback. -` - mdPath := filepath.Join(workflowsDir, "main.md") - require.NoError(t, os.WriteFile(mdPath, []byte(workflowContent), 0644)) - - origDir, err := os.Getwd() - require.NoError(t, err) - require.NoError(t, os.Chdir(workflowsDir)) - defer func() { _ = os.Chdir(origDir) }() - - data, err := compiler.ParseWorkflowFile("main.md") - require.NoError(t, err) - - require.NotNil(t, data.APMDependencies, "APMDependencies should be populated") - require.NotNil(t, data.APMDependencies.GitHubApp, "APMDependencies.GitHubApp should be populated") - assert.Equal(t, "${{ vars.DEPS_APP_ID }}", data.APMDependencies.GitHubApp.AppID, - "APM dependencies should use section-specific github-app, not the top-level fallback") -} diff --git a/pkg/workflow/top_level_github_app_integration_test.go b/pkg/workflow/top_level_github_app_integration_test.go index 8f307568546..339e9d39288 100644 --- a/pkg/workflow/top_level_github_app_integration_test.go +++ b/pkg/workflow/top_level_github_app_integration_test.go @@ -186,47 +186,6 @@ Test workflow verifying top-level github-app fallback for tools.github. "Token minting step should use the top-level APP_ID") }) - t.Run("fallback applied to APM dependencies when no dependencies.github-app", func(t *testing.T) { - content := `--- -name: Top Level GitHub App APM Dependencies Fallback -on: - issues: - types: [opened] -permissions: - contents: read -github-app: - app-id: ${{ vars.APP_ID }} - private-key: ${{ secrets.APP_PRIVATE_KEY }} -dependencies: - packages: - - myorg/private-skill -safe-outputs: - create-issue: - title-prefix: "[automated] " -engine: copilot ---- - -Test workflow verifying top-level github-app fallback for APM dependencies. -` - mdPath := filepath.Join(tmpDir, "test-dependencies-fallback.md") - require.NoError(t, os.WriteFile(mdPath, []byte(content), 0600)) - - compiler := NewCompiler() - err := compiler.CompileWorkflow(mdPath) - require.NoError(t, err, "Workflow with top-level github-app should compile successfully") - - lockPath := filepath.Join(tmpDir, "test-dependencies-fallback.lock.yml") - compiledBytes, err := os.ReadFile(lockPath) - require.NoError(t, err) - compiled := string(compiledBytes) - - // The APM job should have an APM app token minting step using the top-level github-app - assert.Contains(t, compiled, "id: apm-app-token", - "APM job should generate an APM app token minting step using top-level github-app") - assert.Contains(t, compiled, "app-id: ${{ vars.APP_ID }}", - "APM token minting step should use the top-level APP_ID") - }) - t.Run("section-specific github-app takes precedence over top-level", func(t *testing.T) { content := `--- name: Section Specific GitHub App Precedence @@ -350,14 +309,6 @@ func TestTopLevelGitHubAppWorkflowFiles(t *testing.T) { "app-id: ${{ vars.APP_ID }}", }, }, - { - name: "dependencies fallback workflow file", - workflowFile: "../cli/workflows/test-top-level-github-app-dependencies.md", - expectContains: []string{ - "id: apm-app-token", - "app-id: ${{ vars.APP_ID }}", - }, - }, { name: "section-specific override workflow file", workflowFile: "../cli/workflows/test-top-level-github-app-override.md",