From f02a5a412e8d09d8846dec9fd97e8b721c150c72 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 29 Mar 2026 11:41:55 +0000
Subject: [PATCH 01/12] feat: replace microsoft/apm-action pack with JavaScript
implementation (apm_pack.cjs)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Add actions/setup/js/apm_pack.cjs: JS implementation of apm pack --archive
- Mirrors packer.py: reads apm.yml + apm.lock.yaml, filters by target with
cross-target mapping (.github/skills/ → .claude/skills/ etc.), copies files,
writes enriched apm.lock.yaml with pack: header, creates .tar.gz via tar
- Imports parseAPMLockfile/unquoteYaml from apm_unpack.cjs (no duplication)
- Full security: path-traversal rejection, symlink skipping, boundary checks
- Emits bundle-path output via core.setOutput
- ENV: APM_WORKSPACE, APM_BUNDLE_OUTPUT, APM_TARGET
- Add actions/setup/js/run_apm_pack.cjs: standalone entry point for CI/local
- Add actions/setup/js/apm_pack.test.cjs: 57 vitest tests covering
parseApmYml, detectTarget, filterFilesByTarget (all target/cross-target combos),
scalarToYaml, serializeLockfileYaml, security checks, packBundle integration
(real filesystem), main() smoke test
- Update pkg/workflow/apm_dependencies.go: replace GenerateAPMPackStep to
emit two steps instead of microsoft/apm-action:
1. Shell step: pip install apm-cli + apm install (uses GITHUB_APM_PAT)
2. github-script step (id: apm_pack): runs apm_pack.cjs, emits bundle-path
- Update pkg/workflow/apm_dependencies_test.go: update all assertions for
new two-step structure (GITHUB_APM_PAT, apm_pack.cjs, no microsoft/apm-action)
- Extend .github/workflows/ci.yml js-apm-unpack-integration job: test all
4 pack/unpack combinations (py+py, py+js, js+py, js+js) against reference
- Run make recompile: update smoke-claude.lock.yml and golden files
Agent-Logs-Url: https://github.com/github/gh-aw/sessions/f98a6774-0a9e-4331-9403-daf90b81eeeb
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
---
.github/workflows/ci.yml | 140 +++-
.github/workflows/smoke-claude.lock.yml | 33 +-
actions/setup/js/apm_pack.cjs | 717 +++++++++++++++++
actions/setup/js/apm_pack.test.cjs | 740 ++++++++++++++++++
actions/setup/js/run_apm_pack.cjs | 65 ++
pkg/workflow/apm_dependencies.go | 104 ++-
pkg/workflow/apm_dependencies_test.go | 81 +-
.../basic-copilot.golden | 8 +-
.../smoke-copilot.golden | 8 +-
.../with-imports.golden | 8 +-
10 files changed, 1767 insertions(+), 137 deletions(-)
create mode 100644 actions/setup/js/apm_pack.cjs
create mode 100644 actions/setup/js/apm_pack.test.cjs
create mode 100644 actions/setup/js/run_apm_pack.cjs
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index f986ba70028..5bfbe57c02a 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -1034,68 +1034,142 @@ jobs:
echo "✅ APM test project created at $APM_PROJECT"
find "$APM_PROJECT" -type f | sort
- - name: Pack APM bundle
+ - name: Pack APM bundle with Python (reference)
run: |
set -e
cd /tmp/apm-test-project
- mkdir -p /tmp/apm-bundle
- apm pack --archive -o /tmp/apm-bundle
+ mkdir -p /tmp/apm-bundle-python
+ apm pack --archive -o /tmp/apm-bundle-python
echo ""
- echo "✅ Bundle created:"
- ls -lh /tmp/apm-bundle/*.tar.gz
+ echo "✅ Python bundle created:"
+ ls -lh /tmp/apm-bundle-python/*.tar.gz
- - name: Unpack with Python (microsoft/apm reference)
+ - name: Pack APM bundle with JavaScript (apm_pack.cjs)
+ env:
+ APM_WORKSPACE: /tmp/apm-test-project
+ APM_BUNDLE_OUTPUT: /tmp/apm-bundle-js
+ APM_TARGET: all
+ run: |
+ set -e
+ mkdir -p /tmp/apm-bundle-js
+ node actions/setup/js/run_apm_pack.cjs
+ echo ""
+ echo "✅ JavaScript bundle created:"
+ ls -lh /tmp/apm-bundle-js/*.tar.gz
+
+ - name: Unpack Python bundle with Python (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
+ mkdir -p /tmp/apm-out-py-py
+ BUNDLE=$(ls /tmp/apm-bundle-python/*.tar.gz)
+ apm unpack "$BUNDLE" -o /tmp/apm-out-py-py
echo ""
- echo "=== Python unpack result ==="
- find /tmp/apm-out-python -type f | sort
+ echo "=== Python pack + Python unpack ==="
+ find /tmp/apm-out-py-py -type f | sort
- - name: Unpack with JavaScript (apm_unpack.cjs)
+ - name: Unpack Python bundle with JavaScript (apm_unpack.cjs)
env:
- APM_BUNDLE_DIR: /tmp/apm-bundle
- OUTPUT_DIR: /tmp/apm-out-js
+ APM_BUNDLE_DIR: /tmp/apm-bundle-python
+ OUTPUT_DIR: /tmp/apm-out-py-js
run: |
set -e
- mkdir -p /tmp/apm-out-js
+ mkdir -p /tmp/apm-out-py-js
node actions/setup/js/run_apm_unpack.cjs
echo ""
- echo "=== JavaScript unpack result ==="
- find /tmp/apm-out-js -type f | sort
+ echo "=== Python pack + JavaScript unpack ==="
+ find /tmp/apm-out-py-js -type f | sort
- - name: Compare Python vs JavaScript unpack outputs
+ - name: Unpack JavaScript bundle with Python (cross-implementation check)
run: |
set -e
- echo "## APM Unpack Integration Test" >> $GITHUB_STEP_SUMMARY
+ mkdir -p /tmp/apm-out-js-py
+ BUNDLE=$(ls /tmp/apm-bundle-js/*.tar.gz)
+ apm unpack "$BUNDLE" -o /tmp/apm-out-js-py
+ echo ""
+ echo "=== JavaScript pack + Python unpack ==="
+ find /tmp/apm-out-js-py -type f | sort
+
+ - name: Unpack JavaScript bundle with JavaScript
+ env:
+ APM_BUNDLE_DIR: /tmp/apm-bundle-js
+ OUTPUT_DIR: /tmp/apm-out-js-js
+ run: |
+ set -e
+ mkdir -p /tmp/apm-out-js-js
+ node actions/setup/js/run_apm_unpack.cjs
+ echo ""
+ echo "=== JavaScript pack + JavaScript unpack ==="
+ find /tmp/apm-out-js-js -type f | sort
+
+ - name: Compare all pack/unpack combinations against reference
+ run: |
+ set -e
+ echo "## APM Pack/Unpack Integration Test" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
- echo "### Files unpacked by Python (reference)" >> $GITHUB_STEP_SUMMARY
+ # Reference: Python pack + Python unpack
+ echo "### Reference: Python pack + Python unpack" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
- find /tmp/apm-out-python -type f | sort | sed "s|/tmp/apm-out-python/||" >> $GITHUB_STEP_SUMMARY
+ find /tmp/apm-out-py-py -type f | sort | sed "s|/tmp/apm-out-py-py/||" >> $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
+ PASS=true
- 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"
+ # 1. Python pack + JavaScript unpack (pre-existing test)
+ echo "### Python pack + JavaScript unpack" >> $GITHUB_STEP_SUMMARY
+ if diff -rq /tmp/apm-out-py-py /tmp/apm-out-py-js > /tmp/diff1.txt 2>&1; then
+ echo "✅ Identical to reference" >> $GITHUB_STEP_SUMMARY
+ echo "✅ [1/3] Python pack + JS unpack: identical to reference"
else
- echo "### ❌ Outputs differ" >> $GITHUB_STEP_SUMMARY
+ echo "❌ Differs from reference" >> $GITHUB_STEP_SUMMARY
echo '```diff' >> $GITHUB_STEP_SUMMARY
- diff -r /tmp/apm-out-python /tmp/apm-out-js >> $GITHUB_STEP_SUMMARY 2>&1 || true
+ diff -r /tmp/apm-out-py-py /tmp/apm-out-py-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
+ echo "❌ [1/3] Python pack + JS unpack differs from reference:"
+ cat /tmp/diff1.txt
+ PASS=false
+ fi
+
+ # 2. JavaScript pack + Python unpack (validates JS packer output)
+ echo "### JavaScript pack + Python unpack" >> $GITHUB_STEP_SUMMARY
+ if diff -rq /tmp/apm-out-py-py /tmp/apm-out-js-py > /tmp/diff2.txt 2>&1; then
+ echo "✅ Identical to reference" >> $GITHUB_STEP_SUMMARY
+ echo "✅ [2/3] JS pack + Python unpack: identical to reference"
+ else
+ echo "❌ Differs from reference" >> $GITHUB_STEP_SUMMARY
+ echo '```diff' >> $GITHUB_STEP_SUMMARY
+ diff -r /tmp/apm-out-py-py /tmp/apm-out-js-py >> $GITHUB_STEP_SUMMARY 2>&1 || true
+ echo '```' >> $GITHUB_STEP_SUMMARY
+ echo "❌ [2/3] JS pack + Python unpack differs from reference:"
+ cat /tmp/diff2.txt
+ PASS=false
+ fi
+
+ # 3. JavaScript pack + JavaScript unpack (full JS round-trip)
+ echo "### JavaScript pack + JavaScript unpack" >> $GITHUB_STEP_SUMMARY
+ if diff -rq /tmp/apm-out-py-py /tmp/apm-out-js-js > /tmp/diff3.txt 2>&1; then
+ echo "✅ Identical to reference" >> $GITHUB_STEP_SUMMARY
+ echo "✅ [3/3] JS pack + JS unpack: identical to reference"
+ else
+ echo "❌ Differs from reference" >> $GITHUB_STEP_SUMMARY
+ echo '```diff' >> $GITHUB_STEP_SUMMARY
+ diff -r /tmp/apm-out-py-py /tmp/apm-out-js-js >> $GITHUB_STEP_SUMMARY 2>&1 || true
+ echo '```' >> $GITHUB_STEP_SUMMARY
+ echo "❌ [3/3] JS pack + JS unpack differs from reference:"
+ cat /tmp/diff3.txt
+ PASS=false
+ fi
+
+ if [ "$PASS" = "true" ]; then
+ echo "" >> $GITHUB_STEP_SUMMARY
+ echo "### ✅ All 3 comparisons passed" >> $GITHUB_STEP_SUMMARY
+ else
+ echo "" >> $GITHUB_STEP_SUMMARY
+ echo "### ❌ One or more comparisons failed" >> $GITHUB_STEP_SUMMARY
exit 1
fi
+
bench:
# Only run benchmarks on main branch for performance tracking
if: github.ref == 'refs/heads/main'
diff --git a/.github/workflows/smoke-claude.lock.yml b/.github/workflows/smoke-claude.lock.yml
index 3050979ed47..1876e456387 100644
--- a/.github/workflows/smoke-claude.lock.yml
+++ b/.github/workflows/smoke-claude.lock.yml
@@ -2302,20 +2302,31 @@ jobs:
env:
GH_AW_INFO_APM_VERSION: v0.8.6
steps:
- - name: Install and pack APM dependencies
+ - name: Install APM CLI and packages
+ env:
+ GITHUB_APM_PAT: ${{ secrets.GH_AW_PLUGINS_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
+ run: |
+ set -e
+ APM_VERSION="${GH_AW_INFO_APM_VERSION#v}"
+ pip install --quiet "apm-cli==${APM_VERSION}"
+ mkdir -p /tmp/gh-aw/apm-workspace
+ cd /tmp/gh-aw/apm-workspace
+ printf 'name: gh-aw-workspace\nversion: 0.0.0\ndependencies:\n apm:\n' > apm.yml
+ printf ' - microsoft/apm-sample-package\n' >> apm.yml
+ apm install
+ - name: Pack APM bundle
id: apm_pack
- uses: microsoft/apm-action@a190b0b1a91031057144dc136acf9757a59c9e4d # v1.4.1
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
env:
- GITHUB_TOKEN: ${{ secrets.GH_AW_PLUGINS_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
+ APM_WORKSPACE: /tmp/gh-aw/apm-workspace
+ APM_BUNDLE_OUTPUT: /tmp/gh-aw/apm-bundle-output
+ APM_TARGET: claude
with:
- dependencies: |
- - microsoft/apm-sample-package
- isolated: 'true'
- pack: 'true'
- archive: 'true'
- target: claude
- working-directory: /tmp/gh-aw/apm-workspace
- apm-version: ${{ env.GH_AW_INFO_APM_VERSION }}
+ 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_pack.cjs');
+ await main();
- name: Upload APM bundle artifact
if: success()
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
diff --git a/actions/setup/js/apm_pack.cjs b/actions/setup/js/apm_pack.cjs
new file mode 100644
index 00000000000..6b3b1d1f648
--- /dev/null
+++ b/actions/setup/js/apm_pack.cjs
@@ -0,0 +1,717 @@
+// @ts-check
+///
+
+/**
+ * APM Bundle Packer
+ *
+ * JavaScript implementation of the APM (Agent Package Manager) bundle pack
+ * algorithm, equivalent to microsoft/apm packer.py.
+ *
+ * This module creates a self-contained .tar.gz bundle from an already-installed
+ * APM workspace (produced by `apm install`). It replaces the `microsoft/apm-action`
+ * pack step in the APM job, removing the external dependency for packing.
+ *
+ * Algorithm (mirrors packer.py):
+ * 1. Read apm.yml for package name and version (used for bundle directory name)
+ * 2. Read apm.lock.yaml from the workspace
+ * 3. Resolve the effective target (explicit > auto-detect from folder structure)
+ * 4. Collect deployed_files from all dependencies, filtered by target with
+ * cross-target mapping (e.g. .github/skills/ → .claude/skills/ for claude target)
+ * 5. Verify all referenced files exist on disk
+ * 6. Copy files (skip symlinks) to output/-/ preserving structure
+ * 7. Write an enriched apm.lock.yaml with a pack: header to the bundle directory
+ * 8. Create a .tar.gz archive and remove the bundle directory
+ * 9. Emit bundle-path output via core.setOutput
+ *
+ * Environment variables:
+ * APM_WORKSPACE – project root with apm.lock.yaml and installed files
+ * (default: /tmp/gh-aw/apm-workspace)
+ * APM_BUNDLE_OUTPUT – directory where the bundle archive is created
+ * (default: /tmp/gh-aw/apm-bundle-output)
+ * APM_TARGET – pack target: claude, copilot/vscode, cursor, opencode, all
+ * (default: auto-detect from workspace folder structure)
+ *
+ * @module apm_pack
+ */
+
+"use strict";
+
+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";
+
+/** apm.yml filename for package metadata. */
+const APM_YML_NAME = "apm.yml";
+
+// Import shared parsing utilities from apm_unpack to avoid duplication.
+// Globals (core, exec) must be set before this module is loaded.
+const { parseAPMLockfile, unquoteYaml } = require("./apm_unpack.cjs");
+
+// ---------------------------------------------------------------------------
+// Target / cross-target mapping constants (mirrors lockfile_enrichment.py)
+// ---------------------------------------------------------------------------
+
+/**
+ * Authoritative mapping of target names to deployed-file path prefixes.
+ * @type {Record}
+ */
+const TARGET_PREFIXES = {
+ copilot: [".github/"],
+ vscode: [".github/"],
+ claude: [".claude/"],
+ cursor: [".cursor/"],
+ opencode: [".opencode/"],
+ all: [".github/", ".claude/", ".cursor/", ".opencode/"],
+};
+
+/**
+ * Cross-target path equivalences for skills/ and agents/ directories.
+ * Maps bundle_path_prefix → disk_path_prefix for a given target.
+ * Only skills/ and agents/ are semantically identical across targets.
+ * @type {Record>}
+ */
+const CROSS_TARGET_MAPS = {
+ claude: {
+ ".github/skills/": ".claude/skills/",
+ ".github/agents/": ".claude/agents/",
+ },
+ vscode: {
+ ".claude/skills/": ".github/skills/",
+ ".claude/agents/": ".github/agents/",
+ },
+ copilot: {
+ ".claude/skills/": ".github/skills/",
+ ".claude/agents/": ".github/agents/",
+ },
+ cursor: {
+ ".github/skills/": ".cursor/skills/",
+ ".github/agents/": ".cursor/agents/",
+ },
+ opencode: {
+ ".github/skills/": ".opencode/skills/",
+ ".github/agents/": ".opencode/agents/",
+ },
+};
+
+// ---------------------------------------------------------------------------
+// apm.yml parser
+// ---------------------------------------------------------------------------
+
+/**
+ * @typedef {Object} ApmYmlInfo
+ * @property {string} name - Package name (defaults to directory name if missing)
+ * @property {string} version - Package version (defaults to "0.0.0" if missing)
+ */
+
+/**
+ * Parse an apm.yml file to extract package name and version.
+ * These are used to name the bundle directory: -.
+ *
+ * @param {string} content - Raw YAML content of apm.yml
+ * @param {string} [fallbackName] - Fallback name if not found in content
+ * @returns {ApmYmlInfo}
+ */
+function parseApmYml(content, fallbackName = "bundle") {
+ let name = fallbackName;
+ let version = "0.0.0";
+
+ for (const line of content.split("\n")) {
+ const m = line.match(/^(name|version):\s*(.*)$/);
+ if (m) {
+ const v = unquoteYaml(m[2]);
+ if (m[1] === "name" && v !== null && String(v).trim() !== "") {
+ name = String(v).trim();
+ } else if (m[1] === "version" && v !== null && String(v).trim() !== "") {
+ version = String(v).trim();
+ }
+ }
+ }
+
+ return { name, version };
+}
+
+// ---------------------------------------------------------------------------
+// Target detection
+// ---------------------------------------------------------------------------
+
+/**
+ * Detect the effective pack target.
+ *
+ * Priority:
+ * 1. Explicit target (from APM_TARGET environment variable)
+ * 2. Auto-detect from workspace folder structure
+ *
+ * @param {string} workspaceDir - Project root to inspect for target folders
+ * @param {string | null | undefined} explicitTarget - Explicit target string
+ * @returns {string} Normalised target string
+ */
+function detectTarget(workspaceDir, explicitTarget) {
+ if (explicitTarget) {
+ const t = explicitTarget.trim().toLowerCase();
+ if (t === "copilot" || t === "vscode" || t === "agents") return "vscode";
+ if (t === "claude") return "claude";
+ if (t === "cursor") return "cursor";
+ if (t === "opencode") return "opencode";
+ if (t === "all") return "all";
+ core.warning(`[APM Pack] Unknown target '${t}' — falling back to 'all'`);
+ return "all";
+ }
+
+ // Auto-detect from folder structure
+ const hasGitHub = fs.existsSync(path.join(workspaceDir, ".github"));
+ const hasClaude = fs.existsSync(path.join(workspaceDir, ".claude"));
+ const hasCursor = fs.existsSync(path.join(workspaceDir, ".cursor")) && fs.lstatSync(path.join(workspaceDir, ".cursor")).isDirectory();
+ const hasOpencode = fs.existsSync(path.join(workspaceDir, ".opencode")) && fs.lstatSync(path.join(workspaceDir, ".opencode")).isDirectory();
+
+ const detected = [hasGitHub && ".github/", hasClaude && ".claude/", hasCursor && ".cursor/", hasOpencode && ".opencode/"].filter(Boolean);
+
+ if (detected.length >= 2) {
+ core.info(`[APM Pack] Auto-detected target: all (found ${detected.join(" and ")})`);
+ return "all";
+ }
+ if (hasGitHub) {
+ core.info("[APM Pack] Auto-detected target: vscode (found .github/ folder)");
+ return "vscode";
+ }
+ if (hasClaude) {
+ core.info("[APM Pack] Auto-detected target: claude (found .claude/ folder)");
+ return "claude";
+ }
+ if (hasCursor) {
+ core.info("[APM Pack] Auto-detected target: cursor (found .cursor/ folder)");
+ return "cursor";
+ }
+ if (hasOpencode) {
+ core.info("[APM Pack] Auto-detected target: opencode (found .opencode/ folder)");
+ return "opencode";
+ }
+ core.info("[APM Pack] No target folders found — using 'all'");
+ return "all";
+}
+
+// ---------------------------------------------------------------------------
+// File filtering with cross-target mapping
+// ---------------------------------------------------------------------------
+
+/**
+ * @typedef {Object} FilterResult
+ * @property {string[]} files - Filtered (and cross-target mapped) file paths for the bundle.
+ * @property {Record} pathMappings - Maps bundle_path → disk_path for cross-target remaps.
+ */
+
+/**
+ * Filter deployed file paths by target prefix, with cross-target mapping.
+ *
+ * When files are deployed under one target prefix (e.g. .github/skills/)
+ * but the pack target is different (e.g. claude), skills and agents are
+ * remapped to the equivalent target path. Commands, instructions, and
+ * hooks are NOT remapped — they are target-specific.
+ *
+ * Mirrors _filter_files_by_target in lockfile_enrichment.py exactly.
+ *
+ * @param {string[]} deployedFiles - List of relative file paths from deployed_files.
+ * @param {string} target - Normalised target string.
+ * @returns {FilterResult}
+ */
+function filterFilesByTarget(deployedFiles, target) {
+ const prefixes = TARGET_PREFIXES[target] || TARGET_PREFIXES["all"];
+ // Direct matches: files that start with a target prefix
+ const direct = deployedFiles.filter(f => prefixes.some(p => f.startsWith(p)));
+
+ /** @type {Record} */
+ const pathMappings = {};
+ const crossMap = CROSS_TARGET_MAPS[target] || {};
+
+ if (Object.keys(crossMap).length > 0) {
+ const directSet = new Set(direct);
+ for (const f of deployedFiles) {
+ if (directSet.has(f)) continue;
+ for (const [srcPrefix, dstPrefix] of Object.entries(crossMap)) {
+ if (f.startsWith(srcPrefix)) {
+ const mapped = dstPrefix + f.slice(srcPrefix.length);
+ if (!directSet.has(mapped)) {
+ direct.push(mapped);
+ directSet.add(mapped);
+ pathMappings[mapped] = f; // bundle_path → disk_path
+ }
+ break;
+ }
+ }
+ }
+ }
+
+ return { files: direct, pathMappings };
+}
+
+// ---------------------------------------------------------------------------
+// YAML serialization for the enriched lockfile
+// ---------------------------------------------------------------------------
+
+/**
+ * Serialize a scalar value to a YAML string, matching PyYAML safe_dump style.
+ *
+ * Strings that look like YAML keywords or numbers are single-quoted.
+ * Other strings are returned as-is. Booleans and numbers are serialized
+ * without quotes. Null becomes the literal string "null".
+ *
+ * @param {string | number | boolean | null | undefined} value
+ * @returns {string}
+ */
+function scalarToYaml(value) {
+ if (value === null || value === undefined) return "null";
+ if (typeof value === "boolean") return value ? "true" : "false";
+ if (typeof value === "number") return String(value);
+ const s = String(value);
+ // Quote strings that YAML would parse as non-strings (mirrors PyYAML safe_dump quoting)
+ if (
+ s === "" ||
+ s === "null" ||
+ s === "~" ||
+ s === "true" ||
+ s === "false" ||
+ s === "yes" ||
+ s === "no" ||
+ s === "on" ||
+ s === "off" ||
+ /^-?\d+$/.test(s) ||
+ /^-?\d+\.\d+$/.test(s) ||
+ // YAML 1.1 parses ISO 8601 timestamps as datetime objects; quote to preserve string type
+ /^\d{4}-\d{2}-\d{2}T/.test(s)
+ ) {
+ return `'${s.replace(/'/g, "''")}'`;
+ }
+ return s;
+}
+
+/**
+ * Serialize an enriched APM lockfile to YAML.
+ *
+ * The output format matches PyYAML safe_dump: the pack: section is
+ * prepended, followed by top-level metadata, then the dependencies
+ * sequence with filtered deployed_files.
+ *
+ * This output is parseable by both:
+ * - Python yaml.safe_load (used by apm unpack)
+ * - Our parseAPMLockfile (used by apm_unpack.cjs)
+ *
+ * @param {import("./apm_unpack.cjs").APMLockfile} lockfile - Parsed lockfile
+ * @param {import("./apm_unpack.cjs").LockedDependency[]} filteredDeps - Deps with filtered deployed_files
+ * @param {{ format: string, target: string, packed_at: string, mapped_from?: string[] }} packMeta
+ * @returns {string} YAML string
+ */
+function serializeLockfileYaml(lockfile, filteredDeps, packMeta) {
+ const lines = [];
+
+ // Pack metadata section (prepended first, as in Python's enrich_lockfile_for_pack)
+ lines.push("pack:");
+ lines.push(` format: ${packMeta.format}`);
+ lines.push(` target: ${packMeta.target}`);
+ lines.push(` packed_at: ${scalarToYaml(packMeta.packed_at)}`);
+ if (packMeta.mapped_from && packMeta.mapped_from.length > 0) {
+ lines.push(" mapped_from:");
+ for (const prefix of packMeta.mapped_from) {
+ lines.push(` - ${prefix}`);
+ }
+ }
+
+ // Top-level metadata fields
+ if (lockfile.lockfile_version !== null) {
+ lines.push(`lockfile_version: ${scalarToYaml(lockfile.lockfile_version)}`);
+ }
+ if (lockfile.generated_at !== null) {
+ lines.push(`generated_at: ${scalarToYaml(lockfile.generated_at)}`);
+ }
+ if (lockfile.apm_version !== null) {
+ lines.push(`apm_version: ${scalarToYaml(lockfile.apm_version)}`);
+ }
+
+ // Dependencies sequence
+ lines.push("dependencies:");
+ for (const dep of filteredDeps) {
+ lines.push(`- repo_url: ${dep.repo_url}`);
+ if (dep.host !== null) lines.push(` host: ${dep.host}`);
+ else lines.push(` host: null`);
+ if (dep.resolved_commit !== null) lines.push(` resolved_commit: ${dep.resolved_commit}`);
+ else lines.push(` resolved_commit: null`);
+ if (dep.resolved_ref !== null) lines.push(` resolved_ref: ${dep.resolved_ref}`);
+ else lines.push(` resolved_ref: null`);
+ if (dep.version !== null) lines.push(` version: ${scalarToYaml(dep.version)}`);
+ else lines.push(` version: null`);
+ if (dep.virtual_path !== null) lines.push(` virtual_path: ${dep.virtual_path}`);
+ else lines.push(` virtual_path: null`);
+ lines.push(` is_virtual: ${dep.is_virtual ? "true" : "false"}`);
+ lines.push(` depth: ${dep.depth}`);
+ if (dep.resolved_by !== null) lines.push(` resolved_by: ${dep.resolved_by}`);
+ else lines.push(` resolved_by: null`);
+ if (dep.package_type !== null) lines.push(` package_type: ${dep.package_type}`);
+ else lines.push(` package_type: null`);
+ if (dep.source !== null) lines.push(` source: ${dep.source}`);
+ else lines.push(` source: null`);
+ if (dep.local_path !== null) lines.push(` local_path: ${dep.local_path}`);
+ else lines.push(` local_path: null`);
+ if (dep.content_hash !== null) lines.push(` content_hash: ${dep.content_hash}`);
+ else lines.push(` content_hash: null`);
+ lines.push(` is_dev: ${dep.is_dev ? "true" : "false"}`);
+ lines.push(" deployed_files:");
+ for (const f of dep.deployed_files) {
+ lines.push(` - ${f}`);
+ }
+ }
+
+ return lines.join("\n") + "\n";
+}
+
+// ---------------------------------------------------------------------------
+// Security helpers (mirrors assertSafePath / assertDestInsideOutput in unpacker)
+// ---------------------------------------------------------------------------
+
+/**
+ * Validate that a relative path from the lockfile is safe to pack.
+ * Rejects absolute paths and path-traversal attempts.
+ *
+ * @param {string} relPath - Relative path string from deployed_files.
+ * @throws {Error} If the path is unsafe.
+ */
+function assertSafePackPath(relPath) {
+ if (path.isAbsolute(relPath) || relPath.startsWith("/")) {
+ throw new Error(`Refusing to pack unsafe absolute path from lockfile: ${JSON.stringify(relPath)}`);
+ }
+ const parts = relPath.split(/[\\/]/);
+ if (parts.includes("..")) {
+ throw new Error(`Refusing to pack path-traversal entry from lockfile: ${JSON.stringify(relPath)}`);
+ }
+}
+
+/**
+ * Verify the resolved destination path stays within the bundle directory.
+ *
+ * @param {string} destPath - Absolute destination path.
+ * @param {string} bundleDirResolved - Resolved absolute bundle directory.
+ * @throws {Error} If the dest escapes the bundle directory.
+ */
+function assertPackDestInside(destPath, bundleDirResolved) {
+ const resolved = path.resolve(destPath);
+ if (!resolved.startsWith(bundleDirResolved + path.sep) && resolved !== bundleDirResolved) {
+ throw new Error(`Refusing to pack path that escapes the bundle directory: ${JSON.stringify(destPath)}`);
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Copy helpers (mirrors copyDirRecursive / listDirRecursive in apm_unpack)
+// ---------------------------------------------------------------------------
+
+/**
+ * Recursively copy a directory tree from src to dest, skipping symbolic links.
+ *
+ * @param {string} src - Source directory.
+ * @param {string} dest - Destination directory.
+ * @returns {number} Number of files copied.
+ */
+function copyDirForPack(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()) {
+ core.warning(`[APM Pack] Skipping symlink: ${srcPath}`);
+ continue;
+ }
+ if (entry.isDirectory()) {
+ fs.mkdirSync(destPath, { recursive: true });
+ count += copyDirForPack(srcPath, destPath);
+ } else if (entry.isFile()) {
+ fs.mkdirSync(path.dirname(destPath), { recursive: true });
+ fs.copyFileSync(srcPath, destPath);
+ count++;
+ }
+ }
+ return count;
+}
+
+/**
+ * List all file paths recursively under dir, relative to dir. Symbolic links skipped.
+ *
+ * @param {string} dir
+ * @returns {string[]}
+ */
+function listDirForPack(dir) {
+ /** @type {string[]} */
+ const result = [];
+ try {
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
+ for (const entry of entries) {
+ if (entry.isSymbolicLink()) continue;
+ if (entry.isDirectory()) {
+ const sub = listDirForPack(path.join(dir, entry.name));
+ result.push(...sub.map(s => entry.name + "/" + s));
+ } else {
+ result.push(entry.name);
+ }
+ }
+ } catch {
+ // Best-effort listing
+ }
+ return result;
+}
+
+// ---------------------------------------------------------------------------
+// Main pack function
+// ---------------------------------------------------------------------------
+
+/**
+ * @typedef {Object} PackResult
+ * @property {string} bundlePath - Absolute path to the created .tar.gz archive.
+ * @property {string[]} files - Unique list of bundle file paths (filtered by target).
+ * @property {string} target - Effective target used for packing.
+ * @property {Record} pathMappings - Cross-target path mappings used.
+ */
+
+/**
+ * Create a self-contained APM bundle from an installed workspace.
+ *
+ * Mirrors pack_bundle() in packer.py.
+ *
+ * @param {object} params
+ * @param {string} params.workspaceDir - Project root with apm.lock.yaml + installed files.
+ * @param {string} params.outputDir - Directory where the bundle archive will be written.
+ * @param {string | null} [params.target] - Explicit target, or null to auto-detect.
+ * @param {string} [params.format] - Bundle format (default: "apm").
+ * @returns {Promise}
+ */
+async function packBundle({ workspaceDir, outputDir, target = null, format = "apm" }) {
+ core.info("=== APM Bundle Pack ===");
+ core.info(`[APM Pack] Workspace directory : ${workspaceDir}`);
+ core.info(`[APM Pack] Output directory : ${outputDir}`);
+
+ if (!fs.existsSync(workspaceDir)) {
+ throw new Error(`APM workspace directory not found: ${workspaceDir}`);
+ }
+
+ // 1. Read apm.yml for package name / version
+ const apmYmlPath = path.join(workspaceDir, APM_YML_NAME);
+ let pkgName = path.basename(workspaceDir);
+ let pkgVersion = "0.0.0";
+ if (fs.existsSync(apmYmlPath)) {
+ const apmYmlContent = fs.readFileSync(apmYmlPath, "utf-8");
+ const info = parseApmYml(apmYmlContent, pkgName);
+ pkgName = info.name;
+ pkgVersion = info.version;
+ core.info(`[APM Pack] Package : ${pkgName}@${pkgVersion}`);
+ } else {
+ core.warning(`[APM Pack] ${APM_YML_NAME} not found — using directory name and version 0.0.0`);
+ }
+
+ // 2. Read apm.lock.yaml
+ const lockfilePath = path.join(workspaceDir, LOCKFILE_NAME);
+ if (!fs.existsSync(lockfilePath)) {
+ throw new Error(`${LOCKFILE_NAME} not found in workspace: ${workspaceDir}\n` + "Run 'apm install' first to resolve dependencies.");
+ }
+ const lockfileContent = fs.readFileSync(lockfilePath, "utf-8");
+ core.info(`[APM Pack] Lockfile size: ${lockfileContent.length} bytes`);
+
+ // 3. Parse lockfile
+ const lockfile = parseAPMLockfile(lockfileContent);
+ core.info(`[APM Pack] Lockfile version : ${lockfile.lockfile_version}`);
+ core.info(`[APM Pack] APM version : ${lockfile.apm_version}`);
+ core.info(`[APM Pack] Dependencies : ${lockfile.dependencies.length}`);
+
+ // 4. Resolve effective target
+ const effectiveTarget = detectTarget(workspaceDir, target);
+ core.info(`[APM Pack] Target : ${effectiveTarget}`);
+
+ // 5. Collect deployed_files from all dependencies, filtered by target
+ /** @type {string[]} */
+ const allDeployed = [];
+ for (const dep of lockfile.dependencies) {
+ allDeployed.push(...dep.deployed_files);
+ }
+ const { files: filteredFiles, pathMappings } = filterFilesByTarget(allDeployed, effectiveTarget);
+
+ // Deduplicate while preserving order (mirrors Python's seen set)
+ /** @type {Set} */
+ const seen = new Set();
+ /** @type {string[]} */
+ const uniqueFiles = [];
+ for (const f of filteredFiles) {
+ if (!seen.has(f)) {
+ seen.add(f);
+ uniqueFiles.push(f);
+ }
+ }
+ core.info(`[APM Pack] Files to bundle (after filter + dedup): ${uniqueFiles.length}`);
+
+ // 6. Verify each path is safe and exists on disk
+ const workspaceDirResolved = path.resolve(workspaceDir);
+ const missing = [];
+ for (const bundlePath of uniqueFiles) {
+ assertSafePackPath(bundlePath);
+ // For cross-target mapped files, verify the ORIGINAL (on-disk) path
+ const diskRelPath = pathMappings[bundlePath] || bundlePath;
+ // Strip trailing slash for existence check
+ const diskRelPathClean = diskRelPath.endsWith("/") ? diskRelPath.slice(0, -1) : diskRelPath;
+ const absPath = path.join(workspaceDirResolved, diskRelPathClean);
+ // Guard: destination must stay inside workspace
+ const resolvedAbs = path.resolve(absPath);
+ if (!resolvedAbs.startsWith(workspaceDirResolved + path.sep) && resolvedAbs !== workspaceDirResolved) {
+ throw new Error(`Refusing to pack path that escapes workspace directory: ${JSON.stringify(diskRelPath)}`);
+ }
+ if (!fs.existsSync(absPath)) {
+ missing.push(diskRelPath);
+ }
+ }
+ if (missing.length > 0) {
+ throw new Error(`The following deployed files are missing on disk — run 'apm install' to restore them:\n` + missing.map(m => ` - ${m}`).join("\n"));
+ }
+ core.info(`[APM Pack] All ${uniqueFiles.length} file(s) verified on disk`);
+
+ // 7. Build bundle directory: output/-/
+ const bundleDirName = `${pkgName}-${pkgVersion}`;
+ const bundleDir = path.join(path.resolve(outputDir), bundleDirName);
+ const bundleDirResolved = path.resolve(bundleDir);
+ fs.mkdirSync(bundleDir, { recursive: true });
+ core.info(`[APM Pack] Bundle directory : ${bundleDir}`);
+
+ // 8. Copy files preserving directory structure (skip symlinks)
+ let copied = 0;
+ for (const bundleRelPath of uniqueFiles) {
+ const diskRelPath = pathMappings[bundleRelPath] || bundleRelPath;
+ const diskRelPathClean = diskRelPath.endsWith("/") ? diskRelPath.slice(0, -1) : diskRelPath;
+ const bundleRelPathClean = bundleRelPath.endsWith("/") ? bundleRelPath.slice(0, -1) : bundleRelPath;
+ const src = path.join(workspaceDirResolved, diskRelPathClean);
+ const dest = path.join(bundleDir, bundleRelPathClean);
+
+ // Defense-in-depth: verify mapped destination stays inside bundle
+ assertPackDestInside(dest, bundleDirResolved);
+
+ if (!fs.existsSync(src)) continue;
+ const srcLstat = fs.lstatSync(src);
+ if (srcLstat.isSymbolicLink()) {
+ core.warning(`[APM Pack] Skipping symlink: ${diskRelPath}`);
+ continue;
+ }
+
+ if (srcLstat.isDirectory() || bundleRelPath.endsWith("/")) {
+ core.info(`[APM Pack] Copying directory: ${diskRelPath}${pathMappings[bundleRelPath] ? ` → ${bundleRelPathClean}` : ""}`);
+ fs.mkdirSync(dest, { recursive: true });
+ const n = copyDirForPack(src, dest);
+ core.info(`[APM Pack] → Copied ${n} file(s) from ${diskRelPath}`);
+ copied += n;
+ } else {
+ core.info(`[APM Pack] Copying file: ${diskRelPath}${pathMappings[bundleRelPath] ? ` → ${bundleRelPathClean}` : ""}`);
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
+ fs.copyFileSync(src, dest, 0 /* no COPYFILE_EXCL */);
+ copied++;
+ }
+ }
+ core.info(`[APM Pack] Done copying: ${copied} file(s)`);
+
+ // 9. Compute mapped_from for pack: header (source prefixes used in cross-target mapping)
+ const crossMap = CROSS_TARGET_MAPS[effectiveTarget] || {};
+ const usedSrcPrefixes = new Set();
+ for (const original of Object.values(pathMappings)) {
+ for (const srcPrefix of Object.keys(crossMap)) {
+ if (original.startsWith(srcPrefix)) {
+ usedSrcPrefixes.add(srcPrefix);
+ break;
+ }
+ }
+ }
+
+ // Build per-dep filtered dep list (each dep gets deployed_files filtered by target)
+ const filteredDeps = lockfile.dependencies.map(dep => {
+ const { files: depFiles } = filterFilesByTarget(dep.deployed_files, effectiveTarget);
+ return { ...dep, deployed_files: depFiles };
+ });
+
+ // 10. Write enriched apm.lock.yaml to bundle directory
+ const packMeta = {
+ format,
+ target: effectiveTarget,
+ packed_at: new Date().toISOString(),
+ mapped_from: Array.from(usedSrcPrefixes).sort(),
+ };
+ const enrichedLockfile = serializeLockfileYaml(lockfile, filteredDeps, packMeta);
+ const bundleLockfilePath = path.join(bundleDir, LOCKFILE_NAME);
+ fs.writeFileSync(bundleLockfilePath, enrichedLockfile, "utf-8");
+ core.info(`[APM Pack] Wrote enriched lockfile: ${bundleLockfilePath}`);
+
+ // Log bundle contents
+ const bundleFiles = listDirForPack(bundleDir);
+ core.info(`[APM Pack] Bundle contains ${bundleFiles.length} file(s):`);
+ bundleFiles.slice(0, 30).forEach(f => core.info(` ${f}`));
+ if (bundleFiles.length > 30) core.info(` ... and ${bundleFiles.length - 30} more`);
+
+ // 11. Create .tar.gz archive and remove bundle directory
+ const archiveName = `${bundleDirName}.tar.gz`;
+ const archivePath = path.join(path.resolve(outputDir), archiveName);
+ core.info(`[APM Pack] Creating archive: ${archivePath}`);
+ await exec.exec("tar", ["-czf", archivePath, "-C", path.resolve(outputDir), bundleDirName]);
+ fs.rmSync(bundleDir, { recursive: true, force: true });
+ core.info(`[APM Pack] Archive created: ${archivePath}`);
+
+ return {
+ bundlePath: archivePath,
+ files: uniqueFiles,
+ target: effectiveTarget,
+ pathMappings,
+ };
+}
+
+// ---------------------------------------------------------------------------
+// Entry point
+// ---------------------------------------------------------------------------
+
+/**
+ * Main entry point called by the github-script step.
+ *
+ * Reads configuration from environment variables:
+ * APM_WORKSPACE – project root with apm.lock.yaml and installed files
+ * (default: /tmp/gh-aw/apm-workspace)
+ * APM_BUNDLE_OUTPUT – directory where the bundle archive is created
+ * (default: /tmp/gh-aw/apm-bundle-output)
+ * APM_TARGET – pack target (default: auto-detect)
+ */
+async function main() {
+ const workspaceDir = process.env.APM_WORKSPACE || "/tmp/gh-aw/apm-workspace";
+ const outputDir = process.env.APM_BUNDLE_OUTPUT || "/tmp/gh-aw/apm-bundle-output";
+ const target = process.env.APM_TARGET || null;
+
+ core.info("[APM Pack] Starting APM bundle packing");
+ core.info(`[APM Pack] APM_WORKSPACE : ${workspaceDir}`);
+ core.info(`[APM Pack] APM_BUNDLE_OUTPUT : ${outputDir}`);
+ core.info(`[APM Pack] APM_TARGET : ${target || "(auto-detect)"}`);
+
+ try {
+ fs.mkdirSync(outputDir, { recursive: true });
+ const result = await packBundle({ workspaceDir, outputDir, target });
+
+ core.info("[APM Pack] ✅ APM bundle packed successfully");
+ core.info(`[APM Pack] Bundle path : ${result.bundlePath}`);
+ core.info(`[APM Pack] Files bundled : ${result.files.length}`);
+ core.info(`[APM Pack] Target : ${result.target}`);
+
+ core.setOutput("bundle-path", result.bundlePath);
+ } catch (err) {
+ const msg = err instanceof Error ? err.message : String(err);
+ core.error(`[APM Pack] ❌ Failed to pack APM bundle: ${msg}`);
+ throw err;
+ }
+}
+
+module.exports = {
+ main,
+ packBundle,
+ parseApmYml,
+ detectTarget,
+ filterFilesByTarget,
+ serializeLockfileYaml,
+ scalarToYaml,
+ assertSafePackPath,
+ assertPackDestInside,
+ copyDirForPack,
+ listDirForPack,
+};
diff --git a/actions/setup/js/apm_pack.test.cjs b/actions/setup/js/apm_pack.test.cjs
new file mode 100644
index 00000000000..0efa8bd8994
--- /dev/null
+++ b/actions/setup/js/apm_pack.test.cjs
@@ -0,0 +1,740 @@
+// @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 modules
+global.core = mockCore;
+global.exec = mockExec;
+
+const { parseApmYml, detectTarget, filterFilesByTarget, serializeLockfileYaml, scalarToYaml, assertSafePackPath, assertPackDestInside, packBundle } = require("./apm_pack.cjs");
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+/** Create a temp directory and return its path. */
+function makeTempDir() {
+ return fs.mkdtempSync(path.join(os.tmpdir(), "apm-pack-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. Returns absolute path. */
+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;
+}
+
+/**
+ * Build a minimal apm.lock.yaml string with given dependencies.
+ * @param {Array<{repoUrl: string, files: string[]}>} deps
+ */
+function buildLockfile(deps) {
+ const lines = ["lockfile_version: '1'", "apm_version: 0.8.5", "dependencies:"];
+ for (const dep of deps) {
+ lines.push(`- repo_url: ${dep.repoUrl}`);
+ lines.push(` host: github.com`);
+ lines.push(` resolved_commit: abc123`);
+ lines.push(` resolved_ref: main`);
+ lines.push(` depth: 1`);
+ lines.push(` deployed_files:`);
+ for (const f of dep.files) {
+ lines.push(` - ${f}`);
+ }
+ }
+ return lines.join("\n") + "\n";
+}
+
+// ---------------------------------------------------------------------------
+// parseApmYml
+// ---------------------------------------------------------------------------
+
+describe("parseApmYml", () => {
+ it("parses name and version from valid apm.yml", () => {
+ const content = `name: my-package\nversion: 1.2.3\n`;
+ const result = parseApmYml(content);
+ expect(result.name).toBe("my-package");
+ expect(result.version).toBe("1.2.3");
+ });
+
+ it("uses fallback name when name is missing", () => {
+ const content = `version: 1.0.0\n`;
+ const result = parseApmYml(content, "fallback-dir");
+ expect(result.name).toBe("fallback-dir");
+ expect(result.version).toBe("1.0.0");
+ });
+
+ it("uses default version 0.0.0 when version is missing", () => {
+ const content = `name: pkg\n`;
+ const result = parseApmYml(content);
+ expect(result.name).toBe("pkg");
+ expect(result.version).toBe("0.0.0");
+ });
+
+ it("uses defaults when content is empty", () => {
+ const result = parseApmYml("", "my-fallback");
+ expect(result.name).toBe("my-fallback");
+ expect(result.version).toBe("0.0.0");
+ });
+
+ it("handles single-quoted values", () => {
+ const content = `name: 'my-pkg'\nversion: '2.0.0'\n`;
+ const result = parseApmYml(content);
+ expect(result.name).toBe("my-pkg");
+ expect(result.version).toBe("2.0.0");
+ });
+});
+
+// ---------------------------------------------------------------------------
+// detectTarget
+// ---------------------------------------------------------------------------
+
+describe("detectTarget", () => {
+ let tmpDir;
+
+ beforeEach(() => {
+ tmpDir = makeTempDir();
+ });
+ afterEach(() => {
+ removeTempDir(tmpDir);
+ });
+
+ it("returns explicit target normalized to vscode for copilot", () => {
+ expect(detectTarget(tmpDir, "copilot")).toBe("vscode");
+ });
+
+ it("returns explicit target normalized to vscode for vscode", () => {
+ expect(detectTarget(tmpDir, "vscode")).toBe("vscode");
+ });
+
+ it("returns explicit target normalized to vscode for agents", () => {
+ expect(detectTarget(tmpDir, "agents")).toBe("vscode");
+ });
+
+ it("returns claude for explicit claude target", () => {
+ expect(detectTarget(tmpDir, "claude")).toBe("claude");
+ });
+
+ it("returns cursor for explicit cursor target", () => {
+ expect(detectTarget(tmpDir, "cursor")).toBe("cursor");
+ });
+
+ it("returns opencode for explicit opencode target", () => {
+ expect(detectTarget(tmpDir, "opencode")).toBe("opencode");
+ });
+
+ it("returns all for explicit all target", () => {
+ expect(detectTarget(tmpDir, "all")).toBe("all");
+ });
+
+ it("auto-detects vscode when .github/ folder exists", () => {
+ fs.mkdirSync(path.join(tmpDir, ".github"));
+ expect(detectTarget(tmpDir, null)).toBe("vscode");
+ });
+
+ it("auto-detects claude when .claude/ folder exists", () => {
+ fs.mkdirSync(path.join(tmpDir, ".claude"));
+ expect(detectTarget(tmpDir, null)).toBe("claude");
+ });
+
+ it("auto-detects all when both .github/ and .claude/ exist", () => {
+ fs.mkdirSync(path.join(tmpDir, ".github"));
+ fs.mkdirSync(path.join(tmpDir, ".claude"));
+ expect(detectTarget(tmpDir, null)).toBe("all");
+ });
+
+ it("defaults to all when no target folders found", () => {
+ expect(detectTarget(tmpDir, null)).toBe("all");
+ });
+
+ it("explicit target wins over auto-detection", () => {
+ fs.mkdirSync(path.join(tmpDir, ".github"));
+ fs.mkdirSync(path.join(tmpDir, ".claude"));
+ expect(detectTarget(tmpDir, "claude")).toBe("claude");
+ });
+});
+
+// ---------------------------------------------------------------------------
+// filterFilesByTarget
+// ---------------------------------------------------------------------------
+
+describe("filterFilesByTarget – direct matches", () => {
+ it("copilot target includes .github/ files only", () => {
+ const files = [".github/skills/foo/", ".claude/skills/foo/", ".cursor/rules/bar.md"];
+ const { files: result, pathMappings } = filterFilesByTarget(files, "copilot");
+ expect(result).toContain(".github/skills/foo/");
+ expect(result).not.toContain(".claude/skills/foo/");
+ expect(result).not.toContain(".cursor/rules/bar.md");
+ expect(Object.keys(pathMappings)).toHaveLength(0);
+ });
+
+ it("claude target includes .claude/ files only (no cross-map if source is already .claude/)", () => {
+ const files = [".claude/skills/foo/", ".claude/commands/cmd.md"];
+ const { files: result, pathMappings } = filterFilesByTarget(files, "claude");
+ expect(result).toContain(".claude/skills/foo/");
+ expect(result).toContain(".claude/commands/cmd.md");
+ expect(Object.keys(pathMappings)).toHaveLength(0);
+ });
+
+ it("all target includes all target directories", () => {
+ const files = [".github/skills/foo/", ".claude/skills/bar/", ".cursor/rules/x.md"];
+ const { files: result } = filterFilesByTarget(files, "all");
+ expect(result).toContain(".github/skills/foo/");
+ expect(result).toContain(".claude/skills/bar/");
+ expect(result).toContain(".cursor/rules/x.md");
+ });
+
+ it("returns empty array when no files match target", () => {
+ const files = [".github/skills/foo/"];
+ const { files: result } = filterFilesByTarget(files, "claude");
+ // No direct matches, but .github/skills/ → .claude/skills/ cross-map applies
+ expect(result).toContain(".claude/skills/foo/");
+ });
+});
+
+describe("filterFilesByTarget – cross-target mapping", () => {
+ it("claude target maps .github/skills/ to .claude/skills/", () => {
+ const files = [".github/skills/my-skill/"];
+ const { files: result, pathMappings } = filterFilesByTarget(files, "claude");
+ expect(result).toContain(".claude/skills/my-skill/");
+ expect(result).not.toContain(".github/skills/my-skill/");
+ expect(pathMappings[".claude/skills/my-skill/"]).toBe(".github/skills/my-skill/");
+ });
+
+ it("claude target maps .github/agents/ to .claude/agents/", () => {
+ const files = [".github/agents/my-agent.md"];
+ const { files: result, pathMappings } = filterFilesByTarget(files, "claude");
+ expect(result).toContain(".claude/agents/my-agent.md");
+ expect(pathMappings[".claude/agents/my-agent.md"]).toBe(".github/agents/my-agent.md");
+ });
+
+ it("claude target does NOT map .github/instructions/ (target-specific, not remapped)", () => {
+ const files = [".github/copilot-instructions.md"];
+ const { files: result } = filterFilesByTarget(files, "claude");
+ // .github/copilot-instructions.md does not start with .claude/ and has no cross-map
+ expect(result).not.toContain(".github/copilot-instructions.md");
+ });
+
+ it("copilot target maps .claude/skills/ to .github/skills/", () => {
+ const files = [".claude/skills/my-skill/"];
+ const { files: result, pathMappings } = filterFilesByTarget(files, "copilot");
+ expect(result).toContain(".github/skills/my-skill/");
+ expect(pathMappings[".github/skills/my-skill/"]).toBe(".claude/skills/my-skill/");
+ });
+
+ it("cursor target maps .github/skills/ to .cursor/skills/", () => {
+ const files = [".github/skills/my-skill/"];
+ const { files: result, pathMappings } = filterFilesByTarget(files, "cursor");
+ expect(result).toContain(".cursor/skills/my-skill/");
+ expect(pathMappings[".cursor/skills/my-skill/"]).toBe(".github/skills/my-skill/");
+ });
+
+ it("cross-mapped path is not included twice if already directly present", () => {
+ // File already exists under .claude/ AND under .github/ — should not duplicate
+ const files = [".claude/skills/foo/", ".github/skills/foo/"];
+ const { files: result } = filterFilesByTarget(files, "claude");
+ const claudeSkills = result.filter(f => f === ".claude/skills/foo/");
+ expect(claudeSkills).toHaveLength(1); // No duplicates
+ });
+
+ it("all target has no cross-target mappings needed (prefixes cover all dirs)", () => {
+ const files = [".github/skills/foo/", ".claude/skills/bar/"];
+ const { files: result, pathMappings } = filterFilesByTarget(files, "all");
+ expect(result).toContain(".github/skills/foo/");
+ expect(result).toContain(".claude/skills/bar/");
+ expect(Object.keys(pathMappings)).toHaveLength(0); // No mapping needed for "all"
+ });
+});
+
+// ---------------------------------------------------------------------------
+// scalarToYaml
+// ---------------------------------------------------------------------------
+
+describe("scalarToYaml", () => {
+ it("returns 'null' for null/undefined", () => {
+ expect(scalarToYaml(null)).toBe("null");
+ expect(scalarToYaml(undefined)).toBe("null");
+ });
+
+ it("returns true/false for booleans", () => {
+ expect(scalarToYaml(true)).toBe("true");
+ expect(scalarToYaml(false)).toBe("false");
+ });
+
+ it("returns number as string", () => {
+ expect(scalarToYaml(42)).toBe("42");
+ expect(scalarToYaml(0)).toBe("0");
+ });
+
+ it("single-quotes strings that look like YAML keywords", () => {
+ expect(scalarToYaml("null")).toBe("'null'");
+ expect(scalarToYaml("true")).toBe("'true'");
+ expect(scalarToYaml("false")).toBe("'false'");
+ expect(scalarToYaml("yes")).toBe("'yes'");
+ expect(scalarToYaml("no")).toBe("'no'");
+ expect(scalarToYaml("on")).toBe("'on'");
+ expect(scalarToYaml("off")).toBe("'off'");
+ expect(scalarToYaml("")).toBe("''");
+ });
+
+ it("single-quotes strings that look like numbers", () => {
+ expect(scalarToYaml("1")).toBe("'1'");
+ expect(scalarToYaml("42")).toBe("'42'");
+ expect(scalarToYaml("3.14")).toBe("'3.14'");
+ });
+
+ it("single-quotes ISO datetime strings (YAML 1.1 parses them as datetime)", () => {
+ expect(scalarToYaml("2024-01-15T10:00:00.000Z")).toBe("'2024-01-15T10:00:00.000Z'");
+ expect(scalarToYaml("2024-03-29T11:13:45.004Z")).toBe("'2024-03-29T11:13:45.004Z'");
+ });
+
+ it("returns regular strings as-is", () => {
+ expect(scalarToYaml("https://github.com/owner/repo")).toBe("https://github.com/owner/repo");
+ expect(scalarToYaml("main")).toBe("main");
+ expect(scalarToYaml("github.com")).toBe("github.com");
+ expect(scalarToYaml("abc123")).toBe("abc123");
+ });
+});
+
+// ---------------------------------------------------------------------------
+// serializeLockfileYaml
+// ---------------------------------------------------------------------------
+
+describe("serializeLockfileYaml", () => {
+ const baseLockfile = {
+ lockfile_version: "1",
+ generated_at: "2024-01-15T10:00:00.000000+00:00",
+ apm_version: "0.8.5",
+ dependencies: [],
+ pack: {},
+ };
+
+ it("includes pack: section with format, target, packed_at", () => {
+ const yaml = serializeLockfileYaml(baseLockfile, [], {
+ format: "apm",
+ target: "claude",
+ packed_at: "2024-01-15T10:00:00.000Z",
+ });
+ expect(yaml).toContain("pack:");
+ expect(yaml).toContain(" format: apm");
+ expect(yaml).toContain(" target: claude");
+ expect(yaml).toContain(" packed_at: '2024-01-15T10:00:00.000Z'");
+ });
+
+ it("includes mapped_from when cross-target mappings were used", () => {
+ const yaml = serializeLockfileYaml(baseLockfile, [], {
+ format: "apm",
+ target: "claude",
+ packed_at: "2024-01-15T10:00:00.000Z",
+ mapped_from: [".github/agents/", ".github/skills/"],
+ });
+ expect(yaml).toContain(" mapped_from:");
+ expect(yaml).toContain(" - .github/agents/");
+ expect(yaml).toContain(" - .github/skills/");
+ });
+
+ it("omits mapped_from when no cross-target mappings", () => {
+ const yaml = serializeLockfileYaml(baseLockfile, [], {
+ format: "apm",
+ target: "all",
+ packed_at: "2024-01-15T10:00:00.000Z",
+ mapped_from: [],
+ });
+ expect(yaml).not.toContain("mapped_from");
+ });
+
+ it("includes lockfile_version and apm_version", () => {
+ const yaml = serializeLockfileYaml(baseLockfile, [], {
+ format: "apm",
+ target: "all",
+ packed_at: "t",
+ });
+ expect(yaml).toContain("lockfile_version: '1'");
+ expect(yaml).toContain("apm_version: 0.8.5");
+ });
+
+ it("includes filtered deployed_files for each dependency", () => {
+ const dep = {
+ repo_url: "https://github.com/owner/pkg",
+ host: "github.com",
+ resolved_commit: "abc123",
+ resolved_ref: "main",
+ version: null,
+ virtual_path: null,
+ is_virtual: false,
+ depth: 1,
+ resolved_by: null,
+ package_type: null,
+ source: null,
+ local_path: null,
+ content_hash: null,
+ is_dev: false,
+ deployed_files: [".claude/skills/foo/", ".claude/agents/bar.md"],
+ };
+ const yaml = serializeLockfileYaml(baseLockfile, [dep], {
+ format: "apm",
+ target: "claude",
+ packed_at: "t",
+ });
+ expect(yaml).toContain("- repo_url: https://github.com/owner/pkg");
+ expect(yaml).toContain(" - .claude/skills/foo/");
+ expect(yaml).toContain(" - .claude/agents/bar.md");
+ });
+
+ it("pack: section comes before lockfile_version (prepended)", () => {
+ const yaml = serializeLockfileYaml(baseLockfile, [], {
+ format: "apm",
+ target: "all",
+ packed_at: "t",
+ });
+ const packIdx = yaml.indexOf("pack:");
+ const versionIdx = yaml.indexOf("lockfile_version:");
+ expect(packIdx).toBeLessThan(versionIdx);
+ });
+
+ it("output is parseable by parseAPMLockfile from apm_unpack", () => {
+ const { parseAPMLockfile } = require("./apm_unpack.cjs");
+ const dep = {
+ repo_url: "https://github.com/owner/pkg",
+ host: "github.com",
+ resolved_commit: "abc",
+ resolved_ref: "main",
+ version: "1.0.0",
+ virtual_path: null,
+ is_virtual: false,
+ depth: 1,
+ resolved_by: null,
+ package_type: null,
+ source: null,
+ local_path: null,
+ content_hash: null,
+ is_dev: false,
+ deployed_files: [".claude/skills/foo/"],
+ };
+ const yaml = serializeLockfileYaml(baseLockfile, [dep], {
+ format: "apm",
+ target: "claude",
+ packed_at: "2024-01-15T10:00:00.000Z",
+ });
+ const parsed = parseAPMLockfile(yaml);
+ expect(parsed.lockfile_version).toBe("1");
+ expect(parsed.pack.target).toBe("claude");
+ expect(parsed.pack.format).toBe("apm");
+ expect(parsed.dependencies).toHaveLength(1);
+ expect(parsed.dependencies[0].deployed_files).toContain(".claude/skills/foo/");
+ });
+});
+
+// ---------------------------------------------------------------------------
+// assertSafePackPath
+// ---------------------------------------------------------------------------
+
+describe("assertSafePackPath", () => {
+ it("accepts safe relative paths", () => {
+ expect(() => assertSafePackPath(".github/skills/foo/")).not.toThrow();
+ expect(() => assertSafePackPath(".claude/agents/bar.md")).not.toThrow();
+ });
+
+ it("rejects absolute paths", () => {
+ expect(() => assertSafePackPath("/etc/passwd")).toThrow(/unsafe absolute path/);
+ expect(() => assertSafePackPath("/tmp/secret")).toThrow(/unsafe absolute path/);
+ });
+
+ it("rejects path traversal entries", () => {
+ expect(() => assertSafePackPath("../etc/passwd")).toThrow(/path-traversal/);
+ expect(() => assertSafePackPath(".github/../../../etc/passwd")).toThrow(/path-traversal/);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// assertPackDestInside
+// ---------------------------------------------------------------------------
+
+describe("assertPackDestInside", () => {
+ it("accepts paths inside the bundle directory", () => {
+ const bundleDir = "/tmp/my-bundle";
+ expect(() => assertPackDestInside("/tmp/my-bundle/file.txt", bundleDir)).not.toThrow();
+ expect(() => assertPackDestInside("/tmp/my-bundle/subdir/file.txt", bundleDir)).not.toThrow();
+ });
+
+ it("rejects paths that escape the bundle directory", () => {
+ const bundleDir = "/tmp/my-bundle";
+ expect(() => assertPackDestInside("/tmp/other/file.txt", bundleDir)).toThrow(/escapes the bundle/);
+ expect(() => assertPackDestInside("/etc/passwd", bundleDir)).toThrow(/escapes the bundle/);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// packBundle – integration tests with real file system
+// ---------------------------------------------------------------------------
+
+describe("packBundle – integration", () => {
+ let workspaceDir;
+ let outputDir;
+
+ beforeEach(() => {
+ workspaceDir = makeTempDir();
+ outputDir = makeTempDir();
+
+ // Wire up exec.exec to run real tar
+ const { spawnSync } = require("child_process");
+ mockExec.exec.mockImplementation(async (cmd, args = []) => {
+ const result = spawnSync(cmd, args, { stdio: "inherit" });
+ if (result.status !== 0) {
+ throw new Error(`Command failed: ${cmd} ${args.join(" ")} (exit ${result.status})`);
+ }
+ return result.status;
+ });
+ });
+
+ afterEach(() => {
+ removeTempDir(workspaceDir);
+ removeTempDir(outputDir);
+ vi.clearAllMocks();
+ });
+
+ it("packs a simple bundle with .github/ files", async () => {
+ // Create workspace
+ writeFile(workspaceDir, "apm.yml", "name: test-pkg\nversion: 1.0.0\n");
+ const lockfileContent = buildLockfile([
+ {
+ repoUrl: "https://github.com/owner/skill-a",
+ files: [".github/skills/skill-a/"],
+ },
+ ]);
+ writeFile(workspaceDir, "apm.lock.yaml", lockfileContent);
+ writeFile(workspaceDir, ".github/skills/skill-a/skill.md", "# Skill A\n");
+ writeFile(workspaceDir, ".github/skills/skill-a/notes.txt", "Notes\n");
+
+ const result = await packBundle({
+ workspaceDir,
+ outputDir,
+ target: "all",
+ });
+
+ expect(result.bundlePath).toMatch(/test-pkg-1\.0\.0\.tar\.gz$/);
+ expect(fs.existsSync(result.bundlePath)).toBe(true);
+ expect(result.files).toContain(".github/skills/skill-a/");
+ expect(result.target).toBe("all");
+
+ // Verify tar.gz contains expected files
+ const { spawnSync } = require("child_process");
+ const listResult = spawnSync("tar", ["-tzf", result.bundlePath], { encoding: "utf-8" });
+ expect(listResult.status).toBe(0);
+ const entries = listResult.stdout.split("\n").filter(Boolean);
+ expect(entries.some(e => e.includes("skill.md"))).toBe(true);
+ expect(entries.some(e => e.includes(`.github/skills/skill-a`))).toBe(true);
+ expect(entries.some(e => e.includes("apm.lock.yaml"))).toBe(true);
+ });
+
+ it("applies cross-target mapping for claude target", async () => {
+ writeFile(workspaceDir, "apm.yml", "name: cross-test\nversion: 2.0.0\n");
+ const lockfileContent = buildLockfile([
+ {
+ repoUrl: "https://github.com/owner/skills",
+ files: [".github/skills/my-skill/", ".github/copilot-instructions.md"],
+ },
+ ]);
+ writeFile(workspaceDir, "apm.lock.yaml", lockfileContent);
+ writeFile(workspaceDir, ".github/skills/my-skill/skill.md", "# My Skill\n");
+ writeFile(workspaceDir, ".github/copilot-instructions.md", "# Copilot\n");
+
+ const result = await packBundle({
+ workspaceDir,
+ outputDir,
+ target: "claude",
+ });
+
+ expect(result.bundlePath).toMatch(/cross-test-2\.0\.0\.tar\.gz$/);
+ expect(fs.existsSync(result.bundlePath)).toBe(true);
+
+ // The bundle should contain .claude/skills/my-skill/ (mapped from .github/skills/)
+ // but NOT .github/copilot-instructions.md (no cross-map for instructions)
+ const { spawnSync } = require("child_process");
+ const listResult = spawnSync("tar", ["-tzf", result.bundlePath], { encoding: "utf-8" });
+ const entries = listResult.stdout.split("\n").filter(Boolean);
+ expect(entries.some(e => e.includes(".claude/skills/my-skill/skill.md"))).toBe(true);
+ expect(entries.some(e => e.includes("copilot-instructions.md"))).toBe(false);
+
+ // Verify pathMappings
+ expect(result.pathMappings[".claude/skills/my-skill/"]).toBe(".github/skills/my-skill/");
+ });
+
+ it("sets bundle-path output via core.setOutput", async () => {
+ writeFile(workspaceDir, "apm.yml", "name: output-test\nversion: 1.0.0\n");
+ writeFile(workspaceDir, "apm.lock.yaml", buildLockfile([{ repoUrl: "https://github.com/o/r", files: [".github/copilot-instructions.md"] }]));
+ writeFile(workspaceDir, ".github/copilot-instructions.md", "# Instructions\n");
+
+ await packBundle({ workspaceDir, outputDir, target: "all" });
+ // setOutput is called from main() not packBundle directly — test via main()
+ });
+
+ it("throws when apm.lock.yaml is missing", async () => {
+ writeFile(workspaceDir, "apm.yml", "name: missing-lock\nversion: 1.0.0\n");
+ await expect(packBundle({ workspaceDir, outputDir, target: "all" })).rejects.toThrow(/apm\.lock\.yaml not found/);
+ });
+
+ it("throws when a deployed file is missing from disk", async () => {
+ writeFile(workspaceDir, "apm.yml", "name: missing-file\nversion: 1.0.0\n");
+ const lockfileContent = buildLockfile([
+ {
+ repoUrl: "https://github.com/owner/pkg",
+ files: [".github/skills/missing-skill/"],
+ },
+ ]);
+ writeFile(workspaceDir, "apm.lock.yaml", lockfileContent);
+ // Do NOT create .github/skills/missing-skill/ on disk
+
+ await expect(packBundle({ workspaceDir, outputDir, target: "all" })).rejects.toThrow(/missing on disk/);
+ });
+
+ it("skips symlinks in directories (security: never bundle symlinks)", async () => {
+ writeFile(workspaceDir, "apm.yml", "name: symlink-test\nversion: 1.0.0\n");
+ writeFile(workspaceDir, "apm.lock.yaml", buildLockfile([{ repoUrl: "https://github.com/o/r", files: [".github/skills/skill-a/"] }]));
+ writeFile(workspaceDir, ".github/skills/skill-a/real.md", "# Real\n");
+ // Create a symlink inside the skill directory
+ try {
+ fs.symlinkSync("/etc/passwd", path.join(workspaceDir, ".github/skills/skill-a/link.txt"));
+ } catch {
+ // Skip if symlinks not supported
+ return;
+ }
+
+ const result = await packBundle({ workspaceDir, outputDir, target: "all" });
+ expect(fs.existsSync(result.bundlePath)).toBe(true);
+
+ // Verify symlink was not bundled
+ const { spawnSync } = require("child_process");
+ const listResult = spawnSync("tar", ["-tzf", result.bundlePath], { encoding: "utf-8" });
+ const entries = listResult.stdout.split("\n").filter(Boolean);
+ expect(entries.some(e => e.includes("link.txt"))).toBe(false);
+ expect(entries.some(e => e.includes("real.md"))).toBe(true);
+ });
+
+ it("rejects path-traversal entries in deployed_files that pass target filter", async () => {
+ writeFile(workspaceDir, "apm.yml", "name: traversal-test\nversion: 1.0.0\n");
+ // Use a path that starts with .github/ (passes "all" target filter) but contains ..
+ const maliciousLockfile = "lockfile_version: '1'\napm_version: 0.8.5\ndependencies:\n- repo_url: https://github.com/evil/pkg\n depth: 1\n deployed_files:\n - .github/skills/../../etc/passwd\n";
+ writeFile(workspaceDir, "apm.lock.yaml", maliciousLockfile);
+
+ await expect(packBundle({ workspaceDir, outputDir, target: "all" })).rejects.toThrow(/path-traversal/);
+ });
+
+ it("bundles multiple dependencies with deduplication", async () => {
+ writeFile(workspaceDir, "apm.yml", "name: multi-dep\nversion: 1.0.0\n");
+ const lockfileContent = buildLockfile([
+ { repoUrl: "https://github.com/o/pkg-a", files: [".github/skills/skill-a/", ".github/copilot-instructions.md"] },
+ { repoUrl: "https://github.com/o/pkg-b", files: [".github/skills/skill-b/", ".github/copilot-instructions.md"] },
+ ]);
+ writeFile(workspaceDir, "apm.lock.yaml", lockfileContent);
+ writeFile(workspaceDir, ".github/skills/skill-a/skill.md", "# A\n");
+ writeFile(workspaceDir, ".github/skills/skill-b/skill.md", "# B\n");
+ writeFile(workspaceDir, ".github/copilot-instructions.md", "# Both\n");
+
+ const result = await packBundle({ workspaceDir, outputDir, target: "all" });
+ expect(result.files).toContain(".github/skills/skill-a/");
+ expect(result.files).toContain(".github/skills/skill-b/");
+ // .github/copilot-instructions.md should appear only once despite being in both deps
+ const count = result.files.filter(f => f === ".github/copilot-instructions.md").length;
+ expect(count).toBe(1);
+ });
+
+ it("the enriched apm.lock.yaml in the bundle is parseable by parseAPMLockfile", async () => {
+ const { parseAPMLockfile } = require("./apm_unpack.cjs");
+ writeFile(workspaceDir, "apm.yml", "name: parse-test\nversion: 1.0.0\n");
+ writeFile(workspaceDir, "apm.lock.yaml", buildLockfile([{ repoUrl: "https://github.com/o/r", files: [".github/skills/foo/"] }]));
+ writeFile(workspaceDir, ".github/skills/foo/skill.md", "# Foo\n");
+
+ const result = await packBundle({ workspaceDir, outputDir, target: "all" });
+
+ // Extract and read the lockfile from the bundle
+ const extractDir = makeTempDir();
+ try {
+ const { spawnSync } = require("child_process");
+ spawnSync("tar", ["-xzf", result.bundlePath, "-C", extractDir]);
+ // Find the lockfile
+ const bundleDirs = fs.readdirSync(extractDir);
+ expect(bundleDirs.length).toBeGreaterThan(0);
+ const lockfilePath = path.join(extractDir, bundleDirs[0], "apm.lock.yaml");
+ expect(fs.existsSync(lockfilePath)).toBe(true);
+ const lockfileContent = fs.readFileSync(lockfilePath, "utf-8");
+ const parsed = parseAPMLockfile(lockfileContent);
+ expect(parsed.pack.format).toBe("apm");
+ expect(parsed.dependencies.length).toBeGreaterThan(0);
+ } finally {
+ removeTempDir(extractDir);
+ }
+ });
+});
+
+// ---------------------------------------------------------------------------
+// main() – basic smoke test
+// ---------------------------------------------------------------------------
+
+describe("main()", () => {
+ let workspaceDir;
+ let outputDir;
+ let origEnv;
+
+ beforeEach(() => {
+ workspaceDir = makeTempDir();
+ outputDir = makeTempDir();
+ origEnv = { ...process.env };
+
+ const { spawnSync } = require("child_process");
+ mockExec.exec.mockImplementation(async (cmd, args = []) => {
+ const result = spawnSync(cmd, args, { stdio: "inherit" });
+ if (result.status !== 0) throw new Error(`Failed: ${cmd} ${args.join(" ")}`);
+ return result.status;
+ });
+ });
+
+ afterEach(() => {
+ process.env = origEnv;
+ removeTempDir(workspaceDir);
+ removeTempDir(outputDir);
+ vi.clearAllMocks();
+ });
+
+ it("calls core.setOutput with bundle-path on success", async () => {
+ process.env.APM_WORKSPACE = workspaceDir;
+ process.env.APM_BUNDLE_OUTPUT = outputDir;
+ process.env.APM_TARGET = "all";
+
+ writeFile(workspaceDir, "apm.yml", "name: main-test\nversion: 1.0.0\n");
+ writeFile(workspaceDir, "apm.lock.yaml", buildLockfile([{ repoUrl: "https://github.com/o/r", files: [".github/copilot-instructions.md"] }]));
+ writeFile(workspaceDir, ".github/copilot-instructions.md", "# Instructions\n");
+
+ const { main } = require("./apm_pack.cjs");
+ await main();
+
+ expect(mockCore.setOutput).toHaveBeenCalledWith("bundle-path", expect.stringMatching(/\.tar\.gz$/));
+ });
+});
diff --git a/actions/setup/js/run_apm_pack.cjs b/actions/setup/js/run_apm_pack.cjs
new file mode 100644
index 00000000000..d264862b60f
--- /dev/null
+++ b/actions/setup/js/run_apm_pack.cjs
@@ -0,0 +1,65 @@
+// @ts-check
+/**
+ * run_apm_pack.cjs
+ *
+ * Standalone entry-point for apm_pack.cjs used in CI integration tests and
+ * local development. Sets up lightweight CJS-compatible shims for the
+ * @actions/* globals expected by apm_pack.cjs (which imports 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 used by apm_pack.cjs:
+ * core.info / core.warning / core.error / core.setFailed / core.setOutput
+ * exec.exec(cmd, args, options)
+ *
+ * Environment variables (consumed by apm_pack.main):
+ * APM_WORKSPACE – project root with apm.lock.yaml and installed files
+ * APM_BUNDLE_OUTPUT – directory where the bundle archive is written
+ * APM_TARGET – pack target (claude, copilot/vscode, cursor, opencode, all)
+ *
+ * Usage:
+ * node actions/setup/js/run_apm_pack.cjs
+ */
+
+"use strict";
+
+const { spawnSync } = require("child_process");
+const { setupGlobals } = require("./setup_globals.cjs");
+const { main } = require("./apm_pack.cjs");
+
+// Minimal shim for @actions/core — only the methods used by apm_pack.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_pack.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_pack.cjs (and the imported apm_unpack.cjs) can use them.
+setupGlobals(
+ core, // logging, outputs, inputs
+ {}, // @actions/github – not used by apm_pack
+ {}, // GitHub Actions event context – not used by apm_pack
+ exec, // runs `tar -czf`
+ {} // @actions/io – not used by apm_pack
+);
+
+main().catch(err => {
+ console.error(`::error::${err.message}`);
+ process.exit(1);
+});
diff --git a/pkg/workflow/apm_dependencies.go b/pkg/workflow/apm_dependencies.go
index 400ce19d827..a733faa6533 100644
--- a/pkg/workflow/apm_dependencies.go
+++ b/pkg/workflow/apm_dependencies.go
@@ -4,7 +4,6 @@ import (
"fmt"
"sort"
- "github.com/github/gh-aw/pkg/constants"
"github.com/github/gh-aw/pkg/logger"
)
@@ -114,9 +113,17 @@ func buildAPMAppTokenInvalidationStep() []string {
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.
+// GenerateAPMPackStep generates the GitHub Actions steps that install APM packages
+// and pack them into a bundle. This replaces the single microsoft/apm-action step
+// with two steps:
+// 1. A shell run: step that installs the apm-cli via pip and runs apm install
+// to resolve dependencies into the workspace directory.
+// 2. A github-script step (id: apm_pack) that runs apm_pack.cjs to create the
+// .tar.gz bundle and emit the bundle-path output.
+//
+// This eliminates the dependency on microsoft/apm-action for the pack phase.
+// The upload-artifact step in buildAPMJob references ${{ steps.apm_pack.outputs.bundle-path }},
+// so the github-script step must carry id: apm_pack.
//
// Parameters:
// - apmDeps: APM dependency configuration extracted from frontmatter
@@ -132,41 +139,34 @@ func GenerateAPMPackStep(apmDeps *APMDependenciesInfo, target string, data *Work
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.
+ // GITHUB_APM_PAT is the environment variable that the apm CLI uses for authentication.
+ // When a GitHub App is configured, use the minted app token; otherwise use the
+ // cascading fallback token.
hasGitHubAppToken := apmDeps.GitHubApp != nil
- hasUserEnv := len(apmDeps.Env) > 0
- lines = append(lines, " env:")
+
+ var githubAPMPatExpr string
if hasGitHubAppToken {
- lines = append(lines,
- fmt.Sprintf(" GITHUB_TOKEN: ${{ steps.%s.outputs.token }}", apmAppTokenStepID),
- )
+ githubAPMPatExpr = fmt.Sprintf("${{ 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),
- )
+ githubAPMPatExpr = getEffectiveAPMGitHubToken(apmDeps.GitHubToken)
}
- if hasUserEnv {
+
+ // -----------------------------------------------------------------------
+ // Step 1: Install apm-cli and run apm install to resolve packages
+ // -----------------------------------------------------------------------
+ var lines []string
+ lines = append(lines,
+ " - name: Install APM CLI and packages",
+ " env:",
+ " GITHUB_APM_PAT: "+githubAPMPatExpr,
+ )
+
+ // Include any user-provided env vars (skip GITHUB_APM_PAT if already set above)
+ if len(apmDeps.Env) > 0 {
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
+ if k == "GITHUB_APM_PAT" {
+ continue // avoid duplicate key
}
keys = append(keys, k)
}
@@ -176,22 +176,40 @@ func GenerateAPMPackStep(apmDeps *APMDependenciesInfo, target string, data *Work
}
}
+ // Shell script: install apm-cli (strip leading 'v' from version), create workspace,
+ // write apm.yml with the declared packages, and run apm install.
lines = append(lines,
- " with:",
- " dependencies: |",
+ " run: |",
+ " set -e",
+ " APM_VERSION=\"${GH_AW_INFO_APM_VERSION#v}\"",
+ " pip install --quiet \"apm-cli==${APM_VERSION}\"",
+ " mkdir -p /tmp/gh-aw/apm-workspace",
+ " cd /tmp/gh-aw/apm-workspace",
+ " printf 'name: gh-aw-workspace\\nversion: 0.0.0\\ndependencies:\\n apm:\\n' > apm.yml",
)
-
for _, dep := range apmDeps.Packages {
- lines = append(lines, " - "+dep)
+ lines = append(lines, fmt.Sprintf(" printf ' - %s\\n' >> apm.yml", dep))
}
+ lines = append(lines, " apm install")
+ // -----------------------------------------------------------------------
+ // Step 2: Pack the installed workspace with the JavaScript implementation
+ // -----------------------------------------------------------------------
+ githubScriptRef := GetActionPin("actions/github-script")
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 }}",
+ " - name: Pack APM bundle",
+ " id: apm_pack",
+ " uses: "+githubScriptRef,
+ " env:",
+ " APM_WORKSPACE: /tmp/gh-aw/apm-workspace",
+ " APM_BUNDLE_OUTPUT: /tmp/gh-aw/apm-bundle-output",
+ " APM_TARGET: "+target,
+ " with:",
+ " script: |",
+ " const { setupGlobals } = require('"+SetupActionDestination+"/setup_globals.cjs');",
+ " setupGlobals(core, github, context, exec, io);",
+ " const { main } = require('"+SetupActionDestination+"/apm_pack.cjs');",
+ " await main();",
)
return GitHubActionStep(lines)
diff --git a/pkg/workflow/apm_dependencies_test.go b/pkg/workflow/apm_dependencies_test.go
index 8bc7081a22e..3e10fa2dd94 100644
--- a/pkg/workflow/apm_dependencies_test.go
+++ b/pkg/workflow/apm_dependencies_test.go
@@ -350,17 +350,17 @@ func TestGenerateAPMPackStep(t *testing.T) {
apmDeps: &APMDependenciesInfo{Packages: []string{"microsoft/apm-sample-package"}},
target: "copilot",
expectedContains: []string{
- "Install and pack APM dependencies",
- "id: apm_pack",
- "microsoft/apm-action",
- "dependencies: |",
+ "Install APM CLI and packages",
+ "pip install",
+ "apm install",
+ "apm.yml",
"- 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 }}",
+ "Pack APM bundle",
+ "id: apm_pack",
+ "actions/github-script",
+ "apm_pack.cjs",
+ "APM_TARGET: copilot",
+ "APM_WORKSPACE: /tmp/gh-aw/apm-workspace",
},
},
{
@@ -368,13 +368,13 @@ func TestGenerateAPMPackStep(t *testing.T) {
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",
+ "Install APM CLI and packages",
"- microsoft/apm-sample-package",
"- github/skills/review",
- "target: claude",
- "apm-version: ${{ env.GH_AW_INFO_APM_VERSION }}",
+ "Pack APM bundle",
+ "id: apm_pack",
+ "APM_TARGET: claude",
+ "apm_pack.cjs",
},
},
{
@@ -382,16 +382,17 @@ func TestGenerateAPMPackStep(t *testing.T) {
apmDeps: &APMDependenciesInfo{Packages: []string{"microsoft/apm-sample-package"}},
target: "all",
expectedContains: []string{
- "target: all",
- "apm-version: ${{ env.GH_AW_INFO_APM_VERSION }}",
+ "APM_TARGET: all",
+ "apm_pack.cjs",
},
},
{
- name: "Custom APM version still uses env var reference in step",
+ name: "microsoft/apm-action is not referenced in new implementation",
apmDeps: &APMDependenciesInfo{Packages: []string{"microsoft/apm-sample-package"}, Version: "v1.0.0"},
target: "copilot",
expectedContains: []string{
- "apm-version: ${{ env.GH_AW_INFO_APM_VERSION }}",
+ "pip install",
+ "apm install",
},
},
}
@@ -409,6 +410,9 @@ func TestGenerateAPMPackStep(t *testing.T) {
require.NotEmpty(t, step, "Step should not be empty")
combined := combineStepLines(step)
+ // New implementation does not use microsoft/apm-action
+ assert.NotContains(t, combined, "microsoft/apm-action", "New implementation should not reference microsoft/apm-action")
+
for _, expected := range tt.expectedContains {
assert.Contains(t, combined, expected, "Step should contain: %s", expected)
}
@@ -492,7 +496,7 @@ func TestGenerateAPMRestoreStep(t *testing.T) {
}
func TestGenerateAPMPackStepWithGitHubApp(t *testing.T) {
- t.Run("Pack step includes GITHUB_TOKEN env when github-app is configured", func(t *testing.T) {
+ t.Run("Pack step uses GITHUB_APM_PAT from app token when github-app is configured", func(t *testing.T) {
apmDeps := &APMDependenciesInfo{
Packages: []string{"acme-org/acme-skills/plugins/dev-tools"},
GitHubApp: &GitHubAppConfig{
@@ -506,12 +510,13 @@ func TestGenerateAPMPackStepWithGitHubApp(t *testing.T) {
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, "GITHUB_APM_PAT: ${{ steps.apm-app-token.outputs.token }}", "Should inject app token as GITHUB_APM_PAT")
assert.Contains(t, combined, "env:", "Should have env section")
- assert.Contains(t, combined, "- acme-org/acme-skills/plugins/dev-tools", "Should list dependency")
+ assert.Contains(t, combined, "acme-org/acme-skills/plugins/dev-tools", "Should list dependency in apm.yml")
+ assert.NotContains(t, combined, "microsoft/apm-action", "Should not reference microsoft/apm-action")
})
- t.Run("Pack step uses cascading fallback GITHUB_TOKEN without github-app", func(t *testing.T) {
+ t.Run("Pack step uses cascading fallback GITHUB_APM_PAT without github-app", func(t *testing.T) {
apmDeps := &APMDependenciesInfo{
Packages: []string{"microsoft/apm-sample-package"},
}
@@ -521,7 +526,7 @@ func TestGenerateAPMPackStepWithGitHubApp(t *testing.T) {
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, "GITHUB_APM_PAT:", "Should have GITHUB_APM_PAT 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")
@@ -698,7 +703,7 @@ func TestExtractAPMDependenciesEnv(t *testing.T) {
}
func TestGenerateAPMPackStepWithEnv(t *testing.T) {
- t.Run("Pack step includes user env vars and cascading GITHUB_TOKEN", func(t *testing.T) {
+ t.Run("Pack step includes user env vars and cascading GITHUB_APM_PAT", func(t *testing.T) {
apmDeps := &APMDependenciesInfo{
Packages: []string{"microsoft/apm-sample-package"},
Env: map[string]string{
@@ -715,7 +720,7 @@ func TestGenerateAPMPackStepWithEnv(t *testing.T) {
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, "GITHUB_APM_PAT:", "Should have GITHUB_APM_PAT with cascading fallback")
assert.Contains(t, combined, "GH_AW_PLUGINS_TOKEN", "Cascading fallback should include GH_AW_PLUGINS_TOKEN")
})
@@ -736,7 +741,7 @@ func TestGenerateAPMPackStepWithEnv(t *testing.T) {
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, "GITHUB_APM_PAT: ${{ steps.apm-app-token.outputs.token }}", "Should have GITHUB_APM_PAT from app")
assert.Contains(t, combined, "EXTRA: value", "Should include user env var")
})
@@ -764,7 +769,7 @@ func TestGenerateAPMPackStepWithEnv(t *testing.T) {
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) {
+ t.Run("GITHUB_APM_PAT in user env is skipped when github-app is configured", func(t *testing.T) {
apmDeps := &APMDependenciesInfo{
Packages: []string{"acme-org/acme-skills"},
GitHubApp: &GitHubAppConfig{
@@ -772,8 +777,8 @@ func TestGenerateAPMPackStepWithEnv(t *testing.T) {
PrivateKey: "${{ secrets.APP_PRIVATE_KEY }}",
},
Env: map[string]string{
- "GITHUB_TOKEN": "should-be-skipped",
- "OTHER_VAR": "kept",
+ "GITHUB_APM_PAT": "should-be-skipped",
+ "OTHER_VAR": "kept",
},
}
data := &WorkflowData{Name: "test-workflow"}
@@ -782,11 +787,11 @@ func TestGenerateAPMPackStepWithEnv(t *testing.T) {
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, "GITHUB_APM_PAT: ${{ steps.apm-app-token.outputs.token }}", "Should have GITHUB_APM_PAT from app token, not user env")
+ assert.NotContains(t, combined, "should-be-skipped", "User-supplied GITHUB_APM_PAT 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")
+ count := strings.Count(combined, "GITHUB_APM_PAT:")
+ assert.Equal(t, 1, count, "GITHUB_APM_PAT should appear exactly once")
})
}
@@ -842,7 +847,7 @@ func TestGetEffectiveAPMGitHubToken(t *testing.T) {
}
func TestGenerateAPMPackStepWithGitHubToken(t *testing.T) {
- t.Run("Pack step uses custom github-token when specified", func(t *testing.T) {
+ t.Run("Pack step uses custom github-token as GITHUB_APM_PAT when specified", func(t *testing.T) {
apmDeps := &APMDependenciesInfo{
Packages: []string{"microsoft/apm-sample-package"},
GitHubToken: "${{ secrets.MY_TOKEN }}",
@@ -853,7 +858,7 @@ func TestGenerateAPMPackStepWithGitHubToken(t *testing.T) {
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.Contains(t, combined, "GITHUB_APM_PAT: ${{ secrets.MY_TOKEN }}", "Should use custom token directly as GITHUB_APM_PAT")
assert.NotContains(t, combined, "apm-app-token", "Should not reference app token")
})
@@ -867,7 +872,7 @@ func TestGenerateAPMPackStepWithGitHubToken(t *testing.T) {
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, "GITHUB_APM_PAT:", "Should have GITHUB_APM_PAT")
assert.Contains(t, combined, "GH_AW_PLUGINS_TOKEN", "Should include GH_AW_PLUGINS_TOKEN in cascade")
})
@@ -886,7 +891,7 @@ func TestGenerateAPMPackStepWithGitHubToken(t *testing.T) {
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.Contains(t, combined, "GITHUB_APM_PAT: ${{ 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")
})
}
diff --git a/pkg/workflow/testdata/wasm_golden/TestWasmGolden_CompileFixtures/basic-copilot.golden b/pkg/workflow/testdata/wasm_golden/TestWasmGolden_CompileFixtures/basic-copilot.golden
index 3b21a9547cf..db7c45df8d4 100644
--- a/pkg/workflow/testdata/wasm_golden/TestWasmGolden_CompileFixtures/basic-copilot.golden
+++ b/pkg/workflow/testdata/wasm_golden/TestWasmGolden_CompileFixtures/basic-copilot.golden
@@ -54,7 +54,7 @@ jobs:
GH_AW_INFO_STAGED: "false"
GH_AW_INFO_ALLOWED_DOMAINS: '["defaults"]'
GH_AW_INFO_FIREWALL_ENABLED: "true"
- GH_AW_INFO_AWF_VERSION: "v0.26.0"
+ GH_AW_INFO_AWF_VERSION: "v0.25.3"
GH_AW_INFO_AWMG_VERSION: ""
GH_AW_INFO_FIREWALL_TYPE: "squid"
GH_AW_COMPILED_STRICT: "true"
@@ -279,7 +279,7 @@ jobs:
- name: Install GitHub Copilot CLI
run: ${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh latest
- name: Install AWF binary
- run: bash ${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh v0.26.0
+ run: bash ${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh v0.25.3
- name: Determine automatic lockdown mode for GitHub MCP Server
id: determine-automatic-lockdown
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
@@ -291,7 +291,7 @@ jobs:
const determineAutomaticLockdown = require('${{ runner.temp }}/gh-aw/actions/determine_automatic_lockdown.cjs');
await determineAutomaticLockdown(github, context, core);
- name: Download container images
- run: bash ${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh ghcr.io/github/gh-aw-firewall/agent:0.26.0 ghcr.io/github/gh-aw-firewall/api-proxy:0.26.0 ghcr.io/github/gh-aw-firewall/squid:0.26.0 ghcr.io/github/gh-aw-mcpg:v0.2.8 ghcr.io/github/github-mcp-server:v0.32.0
+ run: bash ${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh ghcr.io/github/gh-aw-firewall/agent:0.25.3 ghcr.io/github/gh-aw-firewall/api-proxy:0.25.3 ghcr.io/github/gh-aw-firewall/squid:0.25.3 ghcr.io/github/gh-aw-mcpg:v0.2.8 ghcr.io/github/github-mcp-server:v0.32.0
- name: Start MCP Gateway
id: start-mcp-gateway
env:
@@ -361,7 +361,7 @@ jobs:
set -o pipefail
touch /tmp/gh-aw/agent-step-summary.md
# shellcheck disable=SC1003
- sudo -E awf --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" --env-all --exclude-env COPILOT_GITHUB_TOKEN --exclude-env GITHUB_MCP_SERVER_TOKEN --exclude-env MCP_GATEWAY_API_KEY --allow-domains api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,github.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --audit-dir /tmp/gh-aw/sandbox/firewall/audit --enable-host-access --image-tag 0.26.0 --skip-pull --enable-api-proxy \
+ sudo -E awf --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" --env-all --exclude-env COPILOT_GITHUB_TOKEN --exclude-env GITHUB_MCP_SERVER_TOKEN --exclude-env MCP_GATEWAY_API_KEY --allow-domains api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,github.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --audit-dir /tmp/gh-aw/sandbox/firewall/audit --enable-host-access --image-tag 0.25.3 --skip-pull --enable-api-proxy \
-- /bin/bash -c '/usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --disable-builtin-mcps --allow-all-tools --allow-all-paths --add-dir "${GITHUB_WORKSPACE}" --prompt "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log
env:
COPILOT_AGENT_RUNNER_TYPE: STANDALONE
diff --git a/pkg/workflow/testdata/wasm_golden/TestWasmGolden_CompileFixtures/smoke-copilot.golden b/pkg/workflow/testdata/wasm_golden/TestWasmGolden_CompileFixtures/smoke-copilot.golden
index 398c0c028f6..bdc6e01ea5f 100644
--- a/pkg/workflow/testdata/wasm_golden/TestWasmGolden_CompileFixtures/smoke-copilot.golden
+++ b/pkg/workflow/testdata/wasm_golden/TestWasmGolden_CompileFixtures/smoke-copilot.golden
@@ -68,7 +68,7 @@ jobs:
GH_AW_INFO_STAGED: "false"
GH_AW_INFO_ALLOWED_DOMAINS: '["defaults","node","github","playwright"]'
GH_AW_INFO_FIREWALL_ENABLED: "true"
- GH_AW_INFO_AWF_VERSION: "v0.26.0"
+ GH_AW_INFO_AWF_VERSION: "v0.25.3"
GH_AW_INFO_AWMG_VERSION: ""
GH_AW_INFO_FIREWALL_TYPE: "squid"
GH_AW_COMPILED_STRICT: "true"
@@ -405,7 +405,7 @@ jobs:
- name: Install GitHub Copilot CLI
run: ${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh latest
- name: Install AWF binary
- run: bash ${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh v0.26.0
+ run: bash ${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh v0.25.3
- name: Determine automatic lockdown mode for GitHub MCP Server
id: determine-automatic-lockdown
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
@@ -417,7 +417,7 @@ jobs:
const determineAutomaticLockdown = require('${{ runner.temp }}/gh-aw/actions/determine_automatic_lockdown.cjs');
await determineAutomaticLockdown(github, context, core);
- name: Download container images
- run: bash ${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh ghcr.io/github/gh-aw-firewall/agent:0.26.0 ghcr.io/github/gh-aw-firewall/api-proxy:0.26.0 ghcr.io/github/gh-aw-firewall/squid:0.26.0 ghcr.io/github/gh-aw-mcpg:v0.2.8 ghcr.io/github/github-mcp-server:v0.32.0 ghcr.io/github/serena-mcp-server:latest mcr.microsoft.com/playwright/mcp
+ run: bash ${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh ghcr.io/github/gh-aw-firewall/agent:0.25.3 ghcr.io/github/gh-aw-firewall/api-proxy:0.25.3 ghcr.io/github/gh-aw-firewall/squid:0.25.3 ghcr.io/github/gh-aw-mcpg:v0.2.8 ghcr.io/github/github-mcp-server:v0.32.0 ghcr.io/github/serena-mcp-server:latest mcr.microsoft.com/playwright/mcp
- name: Install gh-aw extension
env:
GH_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
@@ -561,7 +561,7 @@ jobs:
set -o pipefail
touch /tmp/gh-aw/agent-step-summary.md
# shellcheck disable=SC1003
- sudo -E awf --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" --env-all --exclude-env COPILOT_GITHUB_TOKEN --exclude-env GITHUB_MCP_SERVER_TOKEN --exclude-env MCP_GATEWAY_API_KEY --allow-domains '*.githubusercontent.com,*.jsr.io,api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.npms.io,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,bun.sh,cdn.jsdelivr.net,cdn.playwright.dev,codeload.github.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,deb.nodesource.com,deno.land,docs.github.com,esm.sh,get.pnpm.io,github-cloud.githubusercontent.com,github-cloud.s3.amazonaws.com,github.blog,github.com,github.githubassets.com,go.dev,golang.org,googleapis.deno.dev,googlechromelabs.github.io,goproxy.io,host.docker.internal,json-schema.org,json.schemastore.org,jsr.io,keyserver.ubuntu.com,lfs.github.com,nodejs.org,npm.pkg.github.com,npmjs.com,npmjs.org,objects.githubusercontent.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,pkg.go.dev,playwright.download.prss.microsoft.com,ppa.launchpad.net,proxy.golang.org,raw.githubusercontent.com,registry.bower.io,registry.npmjs.com,registry.npmjs.org,registry.yarnpkg.com,repo.yarnpkg.com,s.symcb.com,s.symcd.com,security.ubuntu.com,skimdb.npmjs.com,storage.googleapis.com,sum.golang.org,telemetry.enterprise.githubcopilot.com,telemetry.vercel.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com,www.npmjs.com,www.npmjs.org,yarnpkg.com' --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --audit-dir /tmp/gh-aw/sandbox/firewall/audit --enable-host-access --image-tag 0.26.0 --skip-pull --enable-api-proxy \
+ sudo -E awf --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" --env-all --exclude-env COPILOT_GITHUB_TOKEN --exclude-env GITHUB_MCP_SERVER_TOKEN --exclude-env MCP_GATEWAY_API_KEY --allow-domains '*.githubusercontent.com,*.jsr.io,api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.npms.io,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,bun.sh,cdn.jsdelivr.net,cdn.playwright.dev,codeload.github.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,deb.nodesource.com,deno.land,docs.github.com,esm.sh,get.pnpm.io,github-cloud.githubusercontent.com,github-cloud.s3.amazonaws.com,github.blog,github.com,github.githubassets.com,go.dev,golang.org,googleapis.deno.dev,googlechromelabs.github.io,goproxy.io,host.docker.internal,json-schema.org,json.schemastore.org,jsr.io,keyserver.ubuntu.com,lfs.github.com,nodejs.org,npm.pkg.github.com,npmjs.com,npmjs.org,objects.githubusercontent.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,pkg.go.dev,playwright.download.prss.microsoft.com,ppa.launchpad.net,proxy.golang.org,raw.githubusercontent.com,registry.bower.io,registry.npmjs.com,registry.npmjs.org,registry.yarnpkg.com,repo.yarnpkg.com,s.symcb.com,s.symcd.com,security.ubuntu.com,skimdb.npmjs.com,storage.googleapis.com,sum.golang.org,telemetry.enterprise.githubcopilot.com,telemetry.vercel.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com,www.npmjs.com,www.npmjs.org,yarnpkg.com' --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --audit-dir /tmp/gh-aw/sandbox/firewall/audit --enable-host-access --image-tag 0.25.3 --skip-pull --enable-api-proxy \
-- /bin/bash -c '/usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --disable-builtin-mcps --allow-all-tools --allow-all-paths --add-dir "${GITHUB_WORKSPACE}" --prompt "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log
env:
COPILOT_AGENT_RUNNER_TYPE: STANDALONE
diff --git a/pkg/workflow/testdata/wasm_golden/TestWasmGolden_CompileFixtures/with-imports.golden b/pkg/workflow/testdata/wasm_golden/TestWasmGolden_CompileFixtures/with-imports.golden
index d2ca069ae98..49a30f99366 100644
--- a/pkg/workflow/testdata/wasm_golden/TestWasmGolden_CompileFixtures/with-imports.golden
+++ b/pkg/workflow/testdata/wasm_golden/TestWasmGolden_CompileFixtures/with-imports.golden
@@ -54,7 +54,7 @@ jobs:
GH_AW_INFO_STAGED: "false"
GH_AW_INFO_ALLOWED_DOMAINS: '["defaults"]'
GH_AW_INFO_FIREWALL_ENABLED: "true"
- GH_AW_INFO_AWF_VERSION: "v0.26.0"
+ GH_AW_INFO_AWF_VERSION: "v0.25.3"
GH_AW_INFO_AWMG_VERSION: ""
GH_AW_INFO_FIREWALL_TYPE: "squid"
GH_AW_COMPILED_STRICT: "true"
@@ -282,7 +282,7 @@ jobs:
- name: Install GitHub Copilot CLI
run: ${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh latest
- name: Install AWF binary
- run: bash ${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh v0.26.0
+ run: bash ${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh v0.25.3
- name: Determine automatic lockdown mode for GitHub MCP Server
id: determine-automatic-lockdown
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
@@ -294,7 +294,7 @@ jobs:
const determineAutomaticLockdown = require('${{ runner.temp }}/gh-aw/actions/determine_automatic_lockdown.cjs');
await determineAutomaticLockdown(github, context, core);
- name: Download container images
- run: bash ${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh ghcr.io/github/gh-aw-firewall/agent:0.26.0 ghcr.io/github/gh-aw-firewall/api-proxy:0.26.0 ghcr.io/github/gh-aw-firewall/squid:0.26.0 ghcr.io/github/gh-aw-mcpg:v0.2.8 ghcr.io/github/github-mcp-server:v0.32.0
+ run: bash ${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh ghcr.io/github/gh-aw-firewall/agent:0.25.3 ghcr.io/github/gh-aw-firewall/api-proxy:0.25.3 ghcr.io/github/gh-aw-firewall/squid:0.25.3 ghcr.io/github/gh-aw-mcpg:v0.2.8 ghcr.io/github/github-mcp-server:v0.32.0
- name: Start MCP Gateway
id: start-mcp-gateway
env:
@@ -364,7 +364,7 @@ jobs:
set -o pipefail
touch /tmp/gh-aw/agent-step-summary.md
# shellcheck disable=SC1003
- sudo -E awf --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" --env-all --exclude-env COPILOT_GITHUB_TOKEN --exclude-env GITHUB_MCP_SERVER_TOKEN --exclude-env MCP_GATEWAY_API_KEY --allow-domains api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,github.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --audit-dir /tmp/gh-aw/sandbox/firewall/audit --enable-host-access --image-tag 0.26.0 --skip-pull --enable-api-proxy \
+ sudo -E awf --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" --env-all --exclude-env COPILOT_GITHUB_TOKEN --exclude-env GITHUB_MCP_SERVER_TOKEN --exclude-env MCP_GATEWAY_API_KEY --allow-domains api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,github.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --audit-dir /tmp/gh-aw/sandbox/firewall/audit --enable-host-access --image-tag 0.25.3 --skip-pull --enable-api-proxy \
-- /bin/bash -c '/usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --disable-builtin-mcps --allow-all-tools --allow-all-paths --add-dir "${GITHUB_WORKSPACE}" --prompt "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log
env:
COPILOT_AGENT_RUNNER_TYPE: STANDALONE
From a8e999663b315595b98ce4d0b0af04c3269fd086 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 29 Mar 2026 11:46:39 +0000
Subject: [PATCH 02/12] fix: use echo instead of printf for apm.yml package
entries to prevent printf format string issues
Package names containing '%' characters would be misinterpreted as printf
format specifiers. Switch to 'echo' which passes the string verbatim.
Agent-Logs-Url: https://github.com/github/gh-aw/sessions/f98a6774-0a9e-4331-9403-daf90b81eeeb
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
---
.github/workflows/smoke-claude.lock.yml | 2 +-
pkg/workflow/apm_dependencies.go | 4 +++-
2 files changed, 4 insertions(+), 2 deletions(-)
diff --git a/.github/workflows/smoke-claude.lock.yml b/.github/workflows/smoke-claude.lock.yml
index 1876e456387..eb611d81add 100644
--- a/.github/workflows/smoke-claude.lock.yml
+++ b/.github/workflows/smoke-claude.lock.yml
@@ -2312,7 +2312,7 @@ jobs:
mkdir -p /tmp/gh-aw/apm-workspace
cd /tmp/gh-aw/apm-workspace
printf 'name: gh-aw-workspace\nversion: 0.0.0\ndependencies:\n apm:\n' > apm.yml
- printf ' - microsoft/apm-sample-package\n' >> apm.yml
+ echo ' - microsoft/apm-sample-package' >> apm.yml
apm install
- name: Pack APM bundle
id: apm_pack
diff --git a/pkg/workflow/apm_dependencies.go b/pkg/workflow/apm_dependencies.go
index a733faa6533..700ee982ab6 100644
--- a/pkg/workflow/apm_dependencies.go
+++ b/pkg/workflow/apm_dependencies.go
@@ -188,7 +188,9 @@ func GenerateAPMPackStep(apmDeps *APMDependenciesInfo, target string, data *Work
" printf 'name: gh-aw-workspace\\nversion: 0.0.0\\ndependencies:\\n apm:\\n' > apm.yml",
)
for _, dep := range apmDeps.Packages {
- lines = append(lines, fmt.Sprintf(" printf ' - %s\\n' >> apm.yml", dep))
+ // Use echo instead of printf to avoid printf interpreting dep as a format string
+ // (package names with '%' characters would cause unexpected behavior with printf).
+ lines = append(lines, fmt.Sprintf(" echo ' - %s' >> apm.yml", dep))
}
lines = append(lines, " apm install")
From 2fa9cec62ea14ad86321060d9315fb6a2744f5f8 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 29 Mar 2026 12:02:35 +0000
Subject: [PATCH 03/12] feat: add apm-js-mirror-monitor workflow to sync JS
implementations with microsoft/APM
Adds a daily Claude workflow that:
- Monitors microsoft/APM repo (packer.py, unpacker.py, lockfile_enrichment.py)
for upstream changes using commit SHAs via GitHub API
- Uses cache-memory (/tmp/gh-aw/cache-memory/apm-js-mirror/) to track last-seen
SHAs and avoid redundant API calls when nothing has changed
- Compares TARGET_PREFIXES, CROSS_TARGET_MAPS, algorithm steps, LockedDependency
fields, and lockfile YAML format between Python source and JS files
- Creates a PR with JS fixes when functional differences are found
- Runs vitest tests after each update to validate correctness
- Creates an issue for changes too complex to auto-fix
- Exits with noop when JS files are already in sync
Agent-Logs-Url: https://github.com/github/gh-aw/sessions/af0c4bd6-f73a-424e-b0b3-4dad8300ffa2
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
---
.../workflows/apm-js-mirror-monitor.lock.yml | 1380 +++++++++++++++++
.github/workflows/apm-js-mirror-monitor.md | 269 ++++
2 files changed, 1649 insertions(+)
create mode 100644 .github/workflows/apm-js-mirror-monitor.lock.yml
create mode 100644 .github/workflows/apm-js-mirror-monitor.md
diff --git a/.github/workflows/apm-js-mirror-monitor.lock.yml b/.github/workflows/apm-js-mirror-monitor.lock.yml
new file mode 100644
index 00000000000..d7a828046ba
--- /dev/null
+++ b/.github/workflows/apm-js-mirror-monitor.lock.yml
@@ -0,0 +1,1380 @@
+# ___ _ _
+# / _ \ | | (_)
+# | |_| | __ _ ___ _ __ | |_ _ ___
+# | _ |/ _` |/ _ \ '_ \| __| |/ __|
+# | | | | (_| | __/ | | | |_| | (__
+# \_| |_/\__, |\___|_| |_|\__|_|\___|
+# __/ |
+# _ _ |___/
+# | | | | / _| |
+# | | | | ___ _ __ _ __| |_| | _____ ____
+# | |/\| |/ _ \ '__| |/ /| _| |/ _ \ \ /\ / / ___|
+# \ /\ / (_) | | | | ( | | | | (_) \ V V /\__ \
+# \/ \/ \___/|_| |_|\_\|_| |_|\___/ \_/\_/ |___/
+#
+# This file was automatically generated by gh-aw. DO NOT EDIT.
+#
+# To update this file, edit the corresponding .md file and run:
+# gh aw compile
+# Not all edits will cause changes to this file.
+#
+# For more information: https://github.github.com/gh-aw/introduction/overview/
+#
+# Daily monitor that checks the microsoft/APM Python source (packer.py, unpacker.py) for changes and ensures apm_pack.cjs and apm_unpack.cjs stay in sync; creates a PR when updates are needed
+#
+# gh-aw-metadata: {"schema_version":"v3","frontmatter_hash":"8bae12c1786de151222414b82f8e9b03260fae96b9beca0caf249e1071ed1183","agent_id":"claude"}
+
+name: "APM JavaScript Mirror Monitor"
+"on":
+ schedule:
+ - cron: "45 22 * * *"
+ # Friendly format: daily (scattered)
+ # skip-if-match: is:pr is:open in:title "[apm-js-mirror]" # Skip-if-match processed as search check in pre-activation job
+ workflow_dispatch:
+ inputs:
+ aw_context:
+ default: ""
+ description: Agent caller context (used internally by Agentic Workflows).
+ required: false
+ type: string
+
+permissions: {}
+
+concurrency:
+ group: "gh-aw-${{ github.workflow }}"
+
+run-name: "APM JavaScript Mirror Monitor"
+
+jobs:
+ activation:
+ needs: pre_activation
+ if: needs.pre_activation.outputs.activated == 'true'
+ runs-on: ubuntu-slim
+ permissions:
+ contents: read
+ outputs:
+ comment_id: ""
+ comment_repo: ""
+ lockdown_check_failed: ${{ steps.generate_aw_info.outputs.lockdown_check_failed == 'true' }}
+ model: ${{ steps.generate_aw_info.outputs.model }}
+ secret_verification_result: ${{ steps.validate-secret.outputs.verification_result }}
+ steps:
+ - name: Checkout actions folder
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ repository: github/gh-aw
+ sparse-checkout: |
+ actions
+ persist-credentials: false
+ - name: Setup Scripts
+ uses: ./actions/setup
+ with:
+ destination: ${{ runner.temp }}/gh-aw/actions
+ - name: Generate agentic run info
+ id: generate_aw_info
+ env:
+ GH_AW_INFO_ENGINE_ID: "claude"
+ GH_AW_INFO_ENGINE_NAME: "Claude Code"
+ GH_AW_INFO_MODEL: ${{ vars.GH_AW_MODEL_AGENT_CLAUDE || 'auto' }}
+ GH_AW_INFO_VERSION: "latest"
+ GH_AW_INFO_AGENT_VERSION: "latest"
+ GH_AW_INFO_WORKFLOW_NAME: "APM JavaScript Mirror Monitor"
+ GH_AW_INFO_EXPERIMENTAL: "false"
+ GH_AW_INFO_SUPPORTS_TOOLS_ALLOWLIST: "true"
+ GH_AW_INFO_STAGED: "false"
+ GH_AW_INFO_ALLOWED_DOMAINS: '["defaults","github","api.github.com","raw.githubusercontent.com"]'
+ GH_AW_INFO_FIREWALL_ENABLED: "true"
+ GH_AW_INFO_AWF_VERSION: "v0.25.3"
+ GH_AW_INFO_AWMG_VERSION: ""
+ GH_AW_INFO_FIREWALL_TYPE: "squid"
+ GH_AW_COMPILED_STRICT: "false"
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
+ 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/generate_aw_info.cjs');
+ await main(core, context);
+ - name: Validate ANTHROPIC_API_KEY secret
+ id: validate-secret
+ run: ${RUNNER_TEMP}/gh-aw/actions/validate_multi_secret.sh ANTHROPIC_API_KEY 'Claude Code' https://github.github.com/gh-aw/reference/engines/#anthropic-claude-code
+ env:
+ ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
+ - name: Checkout .github and .agents folders
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
+ sparse-checkout: |
+ .github
+ .agents
+ actions/setup
+ sparse-checkout-cone-mode: true
+ fetch-depth: 1
+ - name: Check workflow file timestamps
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
+ env:
+ GH_AW_WORKFLOW_FILE: "apm-js-mirror-monitor.lock.yml"
+ 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/check_workflow_timestamp_api.cjs');
+ await main();
+ - name: Create prompt with built-in context
+ env:
+ GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt
+ GH_AW_SAFE_OUTPUTS: ${{ runner.temp }}/gh-aw/safeoutputs/outputs.jsonl
+ GH_AW_GITHUB_ACTOR: ${{ github.actor }}
+ GH_AW_GITHUB_EVENT_COMMENT_ID: ${{ github.event.comment.id }}
+ GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER: ${{ github.event.discussion.number }}
+ GH_AW_GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }}
+ GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }}
+ GH_AW_GITHUB_REPOSITORY: ${{ github.repository }}
+ GH_AW_GITHUB_RUN_ID: ${{ github.run_id }}
+ GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }}
+ # poutine:ignore untrusted_checkout_exec
+ run: |
+ bash ${RUNNER_TEMP}/gh-aw/actions/create_prompt_first.sh
+ {
+ cat << 'GH_AW_PROMPT_0b6f75bcad32c8ab_EOF'
+
+ GH_AW_PROMPT_0b6f75bcad32c8ab_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"
+ cat "${RUNNER_TEMP}/gh-aw/prompts/cache_memory_prompt.md"
+ cat "${RUNNER_TEMP}/gh-aw/prompts/safe_outputs_prompt.md"
+ cat << 'GH_AW_PROMPT_0b6f75bcad32c8ab_EOF'
+
+ Tools: create_issue, create_pull_request, missing_tool, missing_data, noop
+ GH_AW_PROMPT_0b6f75bcad32c8ab_EOF
+ cat "${RUNNER_TEMP}/gh-aw/prompts/safe_outputs_create_pull_request.md"
+ cat << 'GH_AW_PROMPT_0b6f75bcad32c8ab_EOF'
+
+
+ The following GitHub context information is available for this workflow:
+ {{#if __GH_AW_GITHUB_ACTOR__ }}
+ - **actor**: __GH_AW_GITHUB_ACTOR__
+ {{/if}}
+ {{#if __GH_AW_GITHUB_REPOSITORY__ }}
+ - **repository**: __GH_AW_GITHUB_REPOSITORY__
+ {{/if}}
+ {{#if __GH_AW_GITHUB_WORKSPACE__ }}
+ - **workspace**: __GH_AW_GITHUB_WORKSPACE__
+ {{/if}}
+ {{#if __GH_AW_GITHUB_EVENT_ISSUE_NUMBER__ }}
+ - **issue-number**: #__GH_AW_GITHUB_EVENT_ISSUE_NUMBER__
+ {{/if}}
+ {{#if __GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER__ }}
+ - **discussion-number**: #__GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER__
+ {{/if}}
+ {{#if __GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER__ }}
+ - **pull-request-number**: #__GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER__
+ {{/if}}
+ {{#if __GH_AW_GITHUB_EVENT_COMMENT_ID__ }}
+ - **comment-id**: __GH_AW_GITHUB_EVENT_COMMENT_ID__
+ {{/if}}
+ {{#if __GH_AW_GITHUB_RUN_ID__ }}
+ - **workflow-run-id**: __GH_AW_GITHUB_RUN_ID__
+ {{/if}}
+
+
+ GH_AW_PROMPT_0b6f75bcad32c8ab_EOF
+ cat "${RUNNER_TEMP}/gh-aw/prompts/github_mcp_tools_with_safeoutputs_prompt.md"
+ cat << 'GH_AW_PROMPT_0b6f75bcad32c8ab_EOF'
+
+ GH_AW_PROMPT_0b6f75bcad32c8ab_EOF
+ cat << 'GH_AW_PROMPT_0b6f75bcad32c8ab_EOF'
+ {{#runtime-import .github/workflows/apm-js-mirror-monitor.md}}
+ GH_AW_PROMPT_0b6f75bcad32c8ab_EOF
+ } > "$GH_AW_PROMPT"
+ - name: Interpolate variables and render templates
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
+ env:
+ GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt
+ GH_AW_GITHUB_REPOSITORY: ${{ github.repository }}
+ GH_AW_GITHUB_RUN_ID: ${{ github.run_id }}
+ 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/interpolate_prompt.cjs');
+ await main();
+ - name: Substitute placeholders
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
+ env:
+ GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt
+ GH_AW_ALLOWED_EXTENSIONS: ''
+ GH_AW_CACHE_DESCRIPTION: ''
+ GH_AW_CACHE_DIR: '/tmp/gh-aw/cache-memory/'
+ GH_AW_GITHUB_ACTOR: ${{ github.actor }}
+ GH_AW_GITHUB_EVENT_COMMENT_ID: ${{ github.event.comment.id }}
+ GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER: ${{ github.event.discussion.number }}
+ GH_AW_GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }}
+ GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }}
+ GH_AW_GITHUB_REPOSITORY: ${{ github.repository }}
+ GH_AW_GITHUB_RUN_ID: ${{ github.run_id }}
+ GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }}
+ GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_ACTIVATED: ${{ needs.pre_activation.outputs.activated }}
+ with:
+ script: |
+ const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
+ setupGlobals(core, github, context, exec, io);
+
+ const substitutePlaceholders = require('${{ runner.temp }}/gh-aw/actions/substitute_placeholders.cjs');
+
+ // Call the substitution function
+ return await substitutePlaceholders({
+ file: process.env.GH_AW_PROMPT,
+ substitutions: {
+ GH_AW_ALLOWED_EXTENSIONS: process.env.GH_AW_ALLOWED_EXTENSIONS,
+ GH_AW_CACHE_DESCRIPTION: process.env.GH_AW_CACHE_DESCRIPTION,
+ GH_AW_CACHE_DIR: process.env.GH_AW_CACHE_DIR,
+ GH_AW_GITHUB_ACTOR: process.env.GH_AW_GITHUB_ACTOR,
+ GH_AW_GITHUB_EVENT_COMMENT_ID: process.env.GH_AW_GITHUB_EVENT_COMMENT_ID,
+ GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER: process.env.GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER,
+ GH_AW_GITHUB_EVENT_ISSUE_NUMBER: process.env.GH_AW_GITHUB_EVENT_ISSUE_NUMBER,
+ GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: process.env.GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER,
+ GH_AW_GITHUB_REPOSITORY: process.env.GH_AW_GITHUB_REPOSITORY,
+ GH_AW_GITHUB_RUN_ID: process.env.GH_AW_GITHUB_RUN_ID,
+ GH_AW_GITHUB_WORKSPACE: process.env.GH_AW_GITHUB_WORKSPACE,
+ GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_ACTIVATED: process.env.GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_ACTIVATED
+ }
+ });
+ - name: Validate prompt placeholders
+ env:
+ GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt
+ # poutine:ignore untrusted_checkout_exec
+ run: bash ${RUNNER_TEMP}/gh-aw/actions/validate_prompt_placeholders.sh
+ - name: Print prompt
+ env:
+ GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt
+ # poutine:ignore untrusted_checkout_exec
+ run: bash ${RUNNER_TEMP}/gh-aw/actions/print_prompt_summary.sh
+ - name: Upload activation artifact
+ if: success()
+ uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
+ with:
+ name: activation
+ path: |
+ /tmp/gh-aw/aw_info.json
+ /tmp/gh-aw/aw-prompts/prompt.txt
+ retention-days: 1
+
+ agent:
+ needs: activation
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ issues: read
+ pull-requests: read
+ concurrency:
+ group: "gh-aw-claude-${{ github.workflow }}"
+ env:
+ DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
+ GH_AW_ASSETS_ALLOWED_EXTS: ""
+ GH_AW_ASSETS_BRANCH: ""
+ GH_AW_ASSETS_MAX_SIZE_KB: 0
+ GH_AW_MCP_LOG_DIR: /tmp/gh-aw/mcp-logs/safeoutputs
+ GH_AW_WORKFLOW_ID_SANITIZED: apmjsmirrormonitor
+ outputs:
+ checkout_pr_success: ${{ steps.checkout-pr.outputs.checkout_pr_success || 'true' }}
+ has_patch: ${{ steps.collect_output.outputs.has_patch }}
+ model: ${{ needs.activation.outputs.model }}
+ output: ${{ steps.collect_output.outputs.output }}
+ output_types: ${{ steps.collect_output.outputs.output_types }}
+ steps:
+ - name: Checkout actions folder
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ repository: github/gh-aw
+ sparse-checkout: |
+ actions
+ persist-credentials: false
+ - name: Setup Scripts
+ uses: ./actions/setup
+ with:
+ destination: ${{ runner.temp }}/gh-aw/actions
+ - name: Set runtime paths
+ id: set-runtime-paths
+ run: |
+ echo "GH_AW_SAFE_OUTPUTS=${RUNNER_TEMP}/gh-aw/safeoutputs/outputs.jsonl" >> "$GITHUB_OUTPUT"
+ echo "GH_AW_SAFE_OUTPUTS_CONFIG_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" >> "$GITHUB_OUTPUT"
+ echo "GH_AW_SAFE_OUTPUTS_TOOLS_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/tools.json" >> "$GITHUB_OUTPUT"
+ - name: Checkout repository
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
+ - name: Create gh-aw temp directory
+ run: bash ${RUNNER_TEMP}/gh-aw/actions/create_gh_aw_tmp_dir.sh
+ - name: Configure gh CLI for GitHub Enterprise
+ run: bash ${RUNNER_TEMP}/gh-aw/actions/configure_gh_for_ghe.sh
+ env:
+ GH_TOKEN: ${{ github.token }}
+ # 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
+ - name: Restore cache-memory file share data
+ uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
+ with:
+ key: memory-${{ env.GH_AW_WORKFLOW_ID_SANITIZED }}-${{ github.run_id }}
+ path: /tmp/gh-aw/cache-memory
+ restore-keys: |
+ memory-${{ env.GH_AW_WORKFLOW_ID_SANITIZED }}-
+ - name: Configure Git credentials
+ env:
+ REPO_NAME: ${{ github.repository }}
+ SERVER_URL: ${{ github.server_url }}
+ run: |
+ git config --global user.email "github-actions[bot]@users.noreply.github.com"
+ git config --global user.name "github-actions[bot]"
+ git config --global am.keepcr true
+ # Re-authenticate git with GitHub token
+ SERVER_URL_STRIPPED="${SERVER_URL#https://}"
+ git remote set-url origin "https://x-access-token:${{ github.token }}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git"
+ echo "Git configured with standard GitHub Actions identity"
+ - name: Checkout PR branch
+ id: checkout-pr
+ if: |
+ github.event.pull_request || github.event.issue.pull_request
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
+ env:
+ GH_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
+ with:
+ github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
+ 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/checkout_pr_branch.cjs');
+ await main();
+ - name: Setup Node.js
+ uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
+ with:
+ node-version: '24'
+ package-manager-cache: false
+ - name: Install AWF binary
+ run: bash ${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh v0.25.3
+ - name: Install Claude Code CLI
+ run: npm install -g @anthropic-ai/claude-code@latest
+ - name: Determine automatic lockdown mode for GitHub MCP Server
+ id: determine-automatic-lockdown
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
+ env:
+ GH_AW_GITHUB_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN }}
+ GH_AW_GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN }}
+ with:
+ script: |
+ const determineAutomaticLockdown = require('${{ runner.temp }}/gh-aw/actions/determine_automatic_lockdown.cjs');
+ await determineAutomaticLockdown(github, context, core);
+ - name: Download container images
+ run: bash ${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh ghcr.io/github/gh-aw-firewall/agent:0.25.3 ghcr.io/github/gh-aw-firewall/api-proxy:0.25.3 ghcr.io/github/gh-aw-firewall/squid:0.25.3 ghcr.io/github/gh-aw-mcpg:v0.2.8 ghcr.io/github/github-mcp-server:v0.32.0 node:lts-alpine
+ - name: Write Safe Outputs Config
+ run: |
+ 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_8458872bb709c2cb_EOF'
+ {"create_issue":{"expires":72,"labels":["automation","dependencies","apm"],"max":1,"title_prefix":"[apm-js-mirror] "},"create_pull_request":{"expires":72,"labels":["automation","dependencies","apm"],"max":1,"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/"],"reviewers":["copilot"],"title_prefix":"[apm-js-mirror] "},"missing_data":{},"missing_tool":{},"noop":{"max":1,"report-as-issue":"true"}}
+ GH_AW_SAFE_OUTPUTS_CONFIG_8458872bb709c2cb_EOF
+ - name: Write Safe Outputs Tools
+ run: |
+ cat > ${RUNNER_TEMP}/gh-aw/safeoutputs/tools_meta.json << 'GH_AW_SAFE_OUTPUTS_TOOLS_META_dc79e1e171b93ffd_EOF'
+ {
+ "description_suffixes": {
+ "create_issue": " CONSTRAINTS: Maximum 1 issue(s) can be created. Title will be prefixed with \"[apm-js-mirror] \". Labels [\"automation\" \"dependencies\" \"apm\"] will be automatically added.",
+ "create_pull_request": " CONSTRAINTS: Maximum 1 pull request(s) can be created. Title will be prefixed with \"[apm-js-mirror] \". Labels [\"automation\" \"dependencies\" \"apm\"] will be automatically added. Reviewers [\"copilot\"] will be assigned."
+ },
+ "repo_params": {},
+ "dynamic_tools": []
+ }
+ GH_AW_SAFE_OUTPUTS_TOOLS_META_dc79e1e171b93ffd_EOF
+ cat > ${RUNNER_TEMP}/gh-aw/safeoutputs/validation.json << 'GH_AW_SAFE_OUTPUTS_VALIDATION_7aa880297afd96b2_EOF'
+ {
+ "create_issue": {
+ "defaultMax": 1,
+ "fields": {
+ "body": {
+ "required": true,
+ "type": "string",
+ "sanitize": true,
+ "maxLength": 65000
+ },
+ "labels": {
+ "type": "array",
+ "itemType": "string",
+ "itemSanitize": true,
+ "itemMaxLength": 128
+ },
+ "parent": {
+ "issueOrPRNumber": true
+ },
+ "repo": {
+ "type": "string",
+ "maxLength": 256
+ },
+ "temporary_id": {
+ "type": "string"
+ },
+ "title": {
+ "required": true,
+ "type": "string",
+ "sanitize": true,
+ "maxLength": 128
+ }
+ }
+ },
+ "create_pull_request": {
+ "defaultMax": 1,
+ "fields": {
+ "body": {
+ "required": true,
+ "type": "string",
+ "sanitize": true,
+ "maxLength": 65000
+ },
+ "branch": {
+ "required": true,
+ "type": "string",
+ "sanitize": true,
+ "maxLength": 256
+ },
+ "draft": {
+ "type": "boolean"
+ },
+ "labels": {
+ "type": "array",
+ "itemType": "string",
+ "itemSanitize": true,
+ "itemMaxLength": 128
+ },
+ "repo": {
+ "type": "string",
+ "maxLength": 256
+ },
+ "title": {
+ "required": true,
+ "type": "string",
+ "sanitize": true,
+ "maxLength": 128
+ }
+ }
+ },
+ "missing_data": {
+ "defaultMax": 20,
+ "fields": {
+ "alternatives": {
+ "type": "string",
+ "sanitize": true,
+ "maxLength": 256
+ },
+ "context": {
+ "type": "string",
+ "sanitize": true,
+ "maxLength": 256
+ },
+ "data_type": {
+ "type": "string",
+ "sanitize": true,
+ "maxLength": 128
+ },
+ "reason": {
+ "type": "string",
+ "sanitize": true,
+ "maxLength": 256
+ }
+ }
+ },
+ "missing_tool": {
+ "defaultMax": 20,
+ "fields": {
+ "alternatives": {
+ "type": "string",
+ "sanitize": true,
+ "maxLength": 512
+ },
+ "reason": {
+ "required": true,
+ "type": "string",
+ "sanitize": true,
+ "maxLength": 256
+ },
+ "tool": {
+ "type": "string",
+ "sanitize": true,
+ "maxLength": 128
+ }
+ }
+ },
+ "noop": {
+ "defaultMax": 1,
+ "fields": {
+ "message": {
+ "required": true,
+ "type": "string",
+ "sanitize": true,
+ "maxLength": 65000
+ }
+ }
+ }
+ }
+ GH_AW_SAFE_OUTPUTS_VALIDATION_7aa880297afd96b2_EOF
+ node ${RUNNER_TEMP}/gh-aw/actions/generate_safe_outputs_tools.cjs
+ - name: Generate Safe Outputs MCP Server Config
+ id: safe-outputs-config
+ run: |
+ # Generate a secure random API key (360 bits of entropy, 40+ chars)
+ # Mask immediately to prevent timing vulnerabilities
+ API_KEY=$(openssl rand -base64 45 | tr -d '/+=')
+ echo "::add-mask::${API_KEY}"
+
+ PORT=3001
+
+ # Set outputs for next steps
+ {
+ echo "safe_outputs_api_key=${API_KEY}"
+ echo "safe_outputs_port=${PORT}"
+ } >> "$GITHUB_OUTPUT"
+
+ echo "Safe Outputs MCP server will run on port ${PORT}"
+
+ - name: Start Safe Outputs MCP HTTP Server
+ id: safe-outputs-start
+ env:
+ DEBUG: '*'
+ GH_AW_SAFE_OUTPUTS_PORT: ${{ steps.safe-outputs-config.outputs.safe_outputs_port }}
+ GH_AW_SAFE_OUTPUTS_API_KEY: ${{ steps.safe-outputs-config.outputs.safe_outputs_api_key }}
+ GH_AW_SAFE_OUTPUTS_TOOLS_PATH: ${{ runner.temp }}/gh-aw/safeoutputs/tools.json
+ GH_AW_SAFE_OUTPUTS_CONFIG_PATH: ${{ runner.temp }}/gh-aw/safeoutputs/config.json
+ GH_AW_MCP_LOG_DIR: /tmp/gh-aw/mcp-logs/safeoutputs
+ run: |
+ # Environment variables are set above to prevent template injection
+ export DEBUG
+ export GH_AW_SAFE_OUTPUTS_PORT
+ export GH_AW_SAFE_OUTPUTS_API_KEY
+ export GH_AW_SAFE_OUTPUTS_TOOLS_PATH
+ export GH_AW_SAFE_OUTPUTS_CONFIG_PATH
+ export GH_AW_MCP_LOG_DIR
+
+ bash ${RUNNER_TEMP}/gh-aw/actions/start_safe_outputs_server.sh
+
+ - name: Start MCP Gateway
+ id: start-mcp-gateway
+ env:
+ GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }}
+ GH_AW_SAFE_OUTPUTS_API_KEY: ${{ steps.safe-outputs-start.outputs.api_key }}
+ GH_AW_SAFE_OUTPUTS_PORT: ${{ steps.safe-outputs-start.outputs.port }}
+ GITHUB_MCP_GUARD_MIN_INTEGRITY: ${{ steps.determine-automatic-lockdown.outputs.min_integrity }}
+ GITHUB_MCP_GUARD_REPOS: ${{ steps.determine-automatic-lockdown.outputs.repos }}
+ GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
+ run: |
+ set -eo pipefail
+ mkdir -p /tmp/gh-aw/mcp-config
+
+ # Export gateway environment variables for MCP config and gateway script
+ export MCP_GATEWAY_PORT="80"
+ export MCP_GATEWAY_DOMAIN="host.docker.internal"
+ MCP_GATEWAY_API_KEY=$(openssl rand -base64 45 | tr -d '/+=')
+ echo "::add-mask::${MCP_GATEWAY_API_KEY}"
+ export MCP_GATEWAY_API_KEY
+ export MCP_GATEWAY_PAYLOAD_DIR="/tmp/gh-aw/mcp-payloads"
+ mkdir -p "${MCP_GATEWAY_PAYLOAD_DIR}"
+ export MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD="524288"
+ export DEBUG="*"
+
+ 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_SAFE_OUTPUTS_PORT -e GH_AW_SAFE_OUTPUTS_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.8'
+
+ cat << GH_AW_MCP_CONFIG_0b687154616ed8db_EOF | bash ${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.sh
+ {
+ "mcpServers": {
+ "github": {
+ "container": "ghcr.io/github/github-mcp-server:v0.32.0",
+ "env": {
+ "GITHUB_HOST": "$GITHUB_SERVER_URL",
+ "GITHUB_PERSONAL_ACCESS_TOKEN": "$GITHUB_MCP_SERVER_TOKEN",
+ "GITHUB_READ_ONLY": "1",
+ "GITHUB_TOOLSETS": "repos,pull_requests"
+ },
+ "guard-policies": {
+ "allow-only": {
+ "min-integrity": "$GITHUB_MCP_GUARD_MIN_INTEGRITY",
+ "repos": "$GITHUB_MCP_GUARD_REPOS"
+ }
+ }
+ },
+ "safeoutputs": {
+ "type": "http",
+ "url": "http://host.docker.internal:$GH_AW_SAFE_OUTPUTS_PORT",
+ "headers": {
+ "Authorization": "$GH_AW_SAFE_OUTPUTS_API_KEY"
+ },
+ "guard-policies": {
+ "write-sink": {
+ "accept": [
+ "*"
+ ]
+ }
+ }
+ }
+ },
+ "gateway": {
+ "port": $MCP_GATEWAY_PORT,
+ "domain": "${MCP_GATEWAY_DOMAIN}",
+ "apiKey": "${MCP_GATEWAY_API_KEY}",
+ "payloadDir": "${MCP_GATEWAY_PAYLOAD_DIR}"
+ }
+ }
+ GH_AW_MCP_CONFIG_0b687154616ed8db_EOF
+ - name: Download activation artifact
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+ with:
+ name: activation
+ path: /tmp/gh-aw
+ - name: Clean git credentials
+ continue-on-error: true
+ run: bash ${RUNNER_TEMP}/gh-aw/actions/clean_git_credentials.sh
+ - name: Execute Claude Code CLI
+ id: agentic_execution
+ # Allowed tools (sorted):
+ # - Bash
+ # - BashOutput
+ # - Edit
+ # - Edit(/tmp/gh-aw/cache-memory/*)
+ # - ExitPlanMode
+ # - Glob
+ # - Grep
+ # - KillBash
+ # - LS
+ # - MultiEdit
+ # - MultiEdit(/tmp/gh-aw/cache-memory/*)
+ # - NotebookEdit
+ # - NotebookRead
+ # - Read
+ # - Read(/tmp/gh-aw/cache-memory/*)
+ # - Task
+ # - TodoWrite
+ # - WebFetch
+ # - Write
+ # - Write(/tmp/gh-aw/cache-memory/*)
+ # - mcp__github__download_workflow_run_artifact
+ # - mcp__github__get_code_scanning_alert
+ # - mcp__github__get_commit
+ # - mcp__github__get_dependabot_alert
+ # - mcp__github__get_discussion
+ # - mcp__github__get_discussion_comments
+ # - mcp__github__get_file_contents
+ # - mcp__github__get_job_logs
+ # - mcp__github__get_label
+ # - mcp__github__get_latest_release
+ # - mcp__github__get_me
+ # - mcp__github__get_notification_details
+ # - mcp__github__get_pull_request
+ # - mcp__github__get_pull_request_comments
+ # - mcp__github__get_pull_request_diff
+ # - mcp__github__get_pull_request_files
+ # - mcp__github__get_pull_request_review_comments
+ # - mcp__github__get_pull_request_reviews
+ # - mcp__github__get_pull_request_status
+ # - mcp__github__get_release_by_tag
+ # - mcp__github__get_secret_scanning_alert
+ # - mcp__github__get_tag
+ # - mcp__github__get_workflow_run
+ # - mcp__github__get_workflow_run_logs
+ # - mcp__github__get_workflow_run_usage
+ # - mcp__github__issue_read
+ # - mcp__github__list_branches
+ # - mcp__github__list_code_scanning_alerts
+ # - mcp__github__list_commits
+ # - mcp__github__list_dependabot_alerts
+ # - mcp__github__list_discussion_categories
+ # - mcp__github__list_discussions
+ # - mcp__github__list_issue_types
+ # - mcp__github__list_issues
+ # - mcp__github__list_label
+ # - mcp__github__list_notifications
+ # - mcp__github__list_pull_requests
+ # - mcp__github__list_releases
+ # - mcp__github__list_secret_scanning_alerts
+ # - mcp__github__list_starred_repositories
+ # - mcp__github__list_tags
+ # - mcp__github__list_workflow_jobs
+ # - mcp__github__list_workflow_run_artifacts
+ # - mcp__github__list_workflow_runs
+ # - mcp__github__list_workflows
+ # - mcp__github__pull_request_read
+ # - mcp__github__search_code
+ # - mcp__github__search_issues
+ # - mcp__github__search_orgs
+ # - mcp__github__search_pull_requests
+ # - mcp__github__search_repositories
+ # - mcp__github__search_users
+ timeout-minutes: 30
+ run: |
+ set -o pipefail
+ touch /tmp/gh-aw/agent-step-summary.md
+ # shellcheck disable=SC1003
+ sudo -E awf --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" --tty --env-all --exclude-env ANTHROPIC_API_KEY --exclude-env GITHUB_MCP_SERVER_TOKEN --exclude-env MCP_GATEWAY_API_KEY --allow-domains '*.githubusercontent.com,anthropic.com,api.anthropic.com,api.github.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,cdn.playwright.dev,codeload.github.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,docs.github.com,files.pythonhosted.org,ghcr.io,github-cloud.githubusercontent.com,github-cloud.s3.amazonaws.com,github.blog,github.com,github.githubassets.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,lfs.github.com,objects.githubusercontent.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,playwright.download.prss.microsoft.com,ppa.launchpad.net,pypi.org,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,sentry.io,statsig.anthropic.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com' --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --audit-dir /tmp/gh-aw/sandbox/firewall/audit --enable-host-access --image-tag 0.25.3 --skip-pull --enable-api-proxy \
+ -- /bin/bash -c 'export PATH="$(find /opt/hostedtoolcache -maxdepth 4 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true && claude --print --disable-slash-commands --no-chrome --mcp-config /tmp/gh-aw/mcp-config/mcp-servers.json --allowed-tools '\''Bash,BashOutput,Edit,Edit(/tmp/gh-aw/cache-memory/*),ExitPlanMode,Glob,Grep,KillBash,LS,MultiEdit,MultiEdit(/tmp/gh-aw/cache-memory/*),NotebookEdit,NotebookRead,Read,Read(/tmp/gh-aw/cache-memory/*),Task,TodoWrite,WebFetch,Write,Write(/tmp/gh-aw/cache-memory/*),mcp__github__download_workflow_run_artifact,mcp__github__get_code_scanning_alert,mcp__github__get_commit,mcp__github__get_dependabot_alert,mcp__github__get_discussion,mcp__github__get_discussion_comments,mcp__github__get_file_contents,mcp__github__get_job_logs,mcp__github__get_label,mcp__github__get_latest_release,mcp__github__get_me,mcp__github__get_notification_details,mcp__github__get_pull_request,mcp__github__get_pull_request_comments,mcp__github__get_pull_request_diff,mcp__github__get_pull_request_files,mcp__github__get_pull_request_review_comments,mcp__github__get_pull_request_reviews,mcp__github__get_pull_request_status,mcp__github__get_release_by_tag,mcp__github__get_secret_scanning_alert,mcp__github__get_tag,mcp__github__get_workflow_run,mcp__github__get_workflow_run_logs,mcp__github__get_workflow_run_usage,mcp__github__issue_read,mcp__github__list_branches,mcp__github__list_code_scanning_alerts,mcp__github__list_commits,mcp__github__list_dependabot_alerts,mcp__github__list_discussion_categories,mcp__github__list_discussions,mcp__github__list_issue_types,mcp__github__list_issues,mcp__github__list_label,mcp__github__list_notifications,mcp__github__list_pull_requests,mcp__github__list_releases,mcp__github__list_secret_scanning_alerts,mcp__github__list_starred_repositories,mcp__github__list_tags,mcp__github__list_workflow_jobs,mcp__github__list_workflow_run_artifacts,mcp__github__list_workflow_runs,mcp__github__list_workflows,mcp__github__pull_request_read,mcp__github__search_code,mcp__github__search_issues,mcp__github__search_orgs,mcp__github__search_pull_requests,mcp__github__search_repositories,mcp__github__search_users'\'' --debug-file /tmp/gh-aw/agent-stdio.log --verbose --permission-mode bypassPermissions --output-format stream-json "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"${GH_AW_MODEL_AGENT_CLAUDE:+ --model "$GH_AW_MODEL_AGENT_CLAUDE"}' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log
+ env:
+ ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
+ BASH_DEFAULT_TIMEOUT_MS: 60000
+ BASH_MAX_TIMEOUT_MS: 60000
+ DISABLE_BUG_COMMAND: 1
+ DISABLE_ERROR_REPORTING: 1
+ DISABLE_TELEMETRY: 1
+ GH_AW_MCP_CONFIG: /tmp/gh-aw/mcp-config/mcp-servers.json
+ GH_AW_MODEL_AGENT_CLAUDE: ${{ vars.GH_AW_MODEL_AGENT_CLAUDE || '' }}
+ GH_AW_PHASE: agent
+ GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt
+ GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }}
+ GH_AW_VERSION: dev
+ GITHUB_AW: true
+ GITHUB_STEP_SUMMARY: /tmp/gh-aw/agent-step-summary.md
+ GITHUB_WORKSPACE: ${{ github.workspace }}
+ GIT_AUTHOR_EMAIL: github-actions[bot]@users.noreply.github.com
+ GIT_AUTHOR_NAME: github-actions[bot]
+ GIT_COMMITTER_EMAIL: github-actions[bot]@users.noreply.github.com
+ GIT_COMMITTER_NAME: github-actions[bot]
+ MCP_TIMEOUT: 120000
+ MCP_TOOL_TIMEOUT: 60000
+ - name: Configure Git credentials
+ env:
+ REPO_NAME: ${{ github.repository }}
+ SERVER_URL: ${{ github.server_url }}
+ run: |
+ git config --global user.email "github-actions[bot]@users.noreply.github.com"
+ git config --global user.name "github-actions[bot]"
+ git config --global am.keepcr true
+ # Re-authenticate git with GitHub token
+ SERVER_URL_STRIPPED="${SERVER_URL#https://}"
+ git remote set-url origin "https://x-access-token:${{ github.token }}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git"
+ echo "Git configured with standard GitHub Actions identity"
+ - name: Stop MCP Gateway
+ if: always()
+ continue-on-error: true
+ env:
+ MCP_GATEWAY_PORT: ${{ steps.start-mcp-gateway.outputs.gateway-port }}
+ MCP_GATEWAY_API_KEY: ${{ steps.start-mcp-gateway.outputs.gateway-api-key }}
+ GATEWAY_PID: ${{ steps.start-mcp-gateway.outputs.gateway-pid }}
+ run: |
+ bash ${RUNNER_TEMP}/gh-aw/actions/stop_mcp_gateway.sh "$GATEWAY_PID"
+ - name: Redact secrets in logs
+ if: always()
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
+ 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/redact_secrets.cjs');
+ await main();
+ env:
+ GH_AW_SECRET_NAMES: 'ANTHROPIC_API_KEY,GH_AW_GITHUB_MCP_SERVER_TOKEN,GH_AW_GITHUB_TOKEN,GITHUB_TOKEN'
+ SECRET_ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
+ SECRET_GH_AW_GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN }}
+ SECRET_GH_AW_GITHUB_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN }}
+ SECRET_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ - name: Append agent step summary
+ if: always()
+ run: bash ${RUNNER_TEMP}/gh-aw/actions/append_agent_step_summary.sh
+ - name: Copy Safe Outputs
+ if: always()
+ env:
+ GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }}
+ run: |
+ mkdir -p /tmp/gh-aw
+ cp "$GH_AW_SAFE_OUTPUTS" /tmp/gh-aw/safeoutputs.jsonl 2>/dev/null || true
+ - name: Ingest agent output
+ id: collect_output
+ if: always()
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
+ env:
+ GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }}
+ GH_AW_ALLOWED_DOMAINS: "*.githubusercontent.com,anthropic.com,api.anthropic.com,api.github.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,cdn.playwright.dev,codeload.github.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,docs.github.com,files.pythonhosted.org,ghcr.io,github-cloud.githubusercontent.com,github-cloud.s3.amazonaws.com,github.blog,github.com,github.githubassets.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,lfs.github.com,objects.githubusercontent.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,playwright.download.prss.microsoft.com,ppa.launchpad.net,pypi.org,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,sentry.io,statsig.anthropic.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com"
+ GITHUB_SERVER_URL: ${{ github.server_url }}
+ GITHUB_API_URL: ${{ github.api_url }}
+ 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/collect_ndjson_output.cjs');
+ await main();
+ - name: Parse agent logs for step summary
+ if: always()
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
+ env:
+ GH_AW_AGENT_OUTPUT: /tmp/gh-aw/agent-stdio.log
+ 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/parse_claude_log.cjs');
+ await main();
+ - name: Parse MCP Gateway logs for step summary
+ if: always()
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
+ 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/parse_mcp_gateway_log.cjs');
+ await main();
+ - name: Print firewall logs
+ if: always()
+ continue-on-error: true
+ env:
+ AWF_LOGS_DIR: /tmp/gh-aw/sandbox/firewall/logs
+ run: |
+ # Fix permissions on firewall logs so they can be uploaded as artifacts
+ # AWF runs with sudo, creating files owned by root
+ sudo chmod -R a+r /tmp/gh-aw/sandbox/firewall/logs 2>/dev/null || true
+ # Only run awf logs summary if awf command exists (it may not be installed if workflow failed before install step)
+ if command -v awf &> /dev/null; then
+ awf logs summary | tee -a "$GITHUB_STEP_SUMMARY"
+ else
+ echo 'AWF binary not installed, skipping firewall log summary'
+ fi
+ - name: Write agent output placeholder if missing
+ if: always()
+ run: |
+ if [ ! -f /tmp/gh-aw/agent_output.json ]; then
+ echo '{"items":[]}' > /tmp/gh-aw/agent_output.json
+ fi
+ - name: Upload cache-memory data as artifact
+ uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
+ if: always()
+ with:
+ name: cache-memory
+ path: /tmp/gh-aw/cache-memory
+ - name: Upload agent artifacts
+ if: always()
+ continue-on-error: true
+ uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
+ with:
+ name: agent
+ path: |
+ /tmp/gh-aw/aw-prompts/prompt.txt
+ /tmp/gh-aw/mcp-logs/
+ /tmp/gh-aw/agent-stdio.log
+ /tmp/gh-aw/agent/
+ /tmp/gh-aw/safeoutputs.jsonl
+ /tmp/gh-aw/agent_output.json
+ /tmp/gh-aw/aw-*.patch
+ /tmp/gh-aw/aw-*.bundle
+ if-no-files-found: ignore
+ - name: Upload firewall audit logs
+ if: always()
+ continue-on-error: true
+ uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
+ with:
+ name: firewall-audit-logs
+ path: |
+ /tmp/gh-aw/sandbox/firewall/logs/
+ /tmp/gh-aw/sandbox/firewall/audit/
+ if-no-files-found: ignore
+
+ conclusion:
+ needs:
+ - activation
+ - agent
+ - detection
+ - safe_outputs
+ - update_cache_memory
+ if: always() && (needs.agent.result != 'skipped' || needs.activation.outputs.lockdown_check_failed == 'true')
+ runs-on: ubuntu-slim
+ permissions:
+ contents: write
+ issues: write
+ pull-requests: write
+ concurrency:
+ group: "gh-aw-conclusion-apm-js-mirror-monitor"
+ cancel-in-progress: false
+ outputs:
+ noop_message: ${{ steps.noop.outputs.noop_message }}
+ tools_reported: ${{ steps.missing_tool.outputs.tools_reported }}
+ total_count: ${{ steps.missing_tool.outputs.total_count }}
+ steps:
+ - name: Checkout actions folder
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ repository: github/gh-aw
+ sparse-checkout: |
+ actions
+ persist-credentials: false
+ - name: Setup Scripts
+ uses: ./actions/setup
+ with:
+ destination: ${{ runner.temp }}/gh-aw/actions
+ - name: Download agent output artifact
+ id: download-agent-output
+ continue-on-error: true
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+ with:
+ name: agent
+ path: /tmp/gh-aw/
+ - name: Setup agent output environment variable
+ id: setup-agent-output-env
+ if: steps.download-agent-output.outcome == 'success'
+ run: |
+ mkdir -p /tmp/gh-aw/
+ find "/tmp/gh-aw/" -type f -print
+ echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT"
+ - name: Process No-Op Messages
+ id: noop
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
+ env:
+ GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }}
+ GH_AW_NOOP_MAX: "1"
+ GH_AW_WORKFLOW_NAME: "APM JavaScript Mirror Monitor"
+ GH_AW_TRACKER_ID: "apm-js-mirror-monitor"
+ with:
+ github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
+ 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/noop.cjs');
+ await main();
+ - name: Record Missing Tool
+ id: missing_tool
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
+ env:
+ GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }}
+ GH_AW_MISSING_TOOL_CREATE_ISSUE: "true"
+ GH_AW_WORKFLOW_NAME: "APM JavaScript Mirror Monitor"
+ GH_AW_TRACKER_ID: "apm-js-mirror-monitor"
+ with:
+ github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
+ 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/missing_tool.cjs');
+ await main();
+ - name: Handle Agent Failure
+ id: handle_agent_failure
+ if: always()
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
+ env:
+ GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }}
+ GH_AW_WORKFLOW_NAME: "APM JavaScript Mirror Monitor"
+ GH_AW_TRACKER_ID: "apm-js-mirror-monitor"
+ GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
+ GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }}
+ GH_AW_WORKFLOW_ID: "apm-js-mirror-monitor"
+ GH_AW_SECRET_VERIFICATION_RESULT: ${{ needs.activation.outputs.secret_verification_result }}
+ GH_AW_CHECKOUT_PR_SUCCESS: ${{ needs.agent.outputs.checkout_pr_success }}
+ GH_AW_CODE_PUSH_FAILURE_ERRORS: ${{ needs.safe_outputs.outputs.code_push_failure_errors }}
+ GH_AW_CODE_PUSH_FAILURE_COUNT: ${{ needs.safe_outputs.outputs.code_push_failure_count }}
+ GH_AW_LOCKDOWN_CHECK_FAILED: ${{ needs.activation.outputs.lockdown_check_failed }}
+ GH_AW_GROUP_REPORTS: "false"
+ GH_AW_FAILURE_REPORT_AS_ISSUE: "true"
+ GH_AW_TIMEOUT_MINUTES: "30"
+ with:
+ github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
+ 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/handle_agent_failure.cjs');
+ await main();
+ - name: Handle No-Op Message
+ id: handle_noop_message
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
+ env:
+ GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }}
+ GH_AW_WORKFLOW_NAME: "APM JavaScript Mirror Monitor"
+ GH_AW_TRACKER_ID: "apm-js-mirror-monitor"
+ GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
+ GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }}
+ GH_AW_NOOP_MESSAGE: ${{ steps.noop.outputs.noop_message }}
+ GH_AW_NOOP_REPORT_AS_ISSUE: "true"
+ with:
+ github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
+ 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/handle_noop_message.cjs');
+ await main();
+ - name: Handle Create Pull Request Error
+ id: handle_create_pr_error
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
+ env:
+ GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }}
+ GH_AW_WORKFLOW_NAME: "APM JavaScript Mirror Monitor"
+ GH_AW_TRACKER_ID: "apm-js-mirror-monitor"
+ GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
+ with:
+ github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
+ 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/handle_create_pr_error.cjs');
+ await main();
+
+ detection:
+ needs: agent
+ if: >
+ always() && needs.agent.result != 'skipped' && (needs.agent.outputs.output_types != '' || needs.agent.outputs.has_patch == 'true')
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ outputs:
+ detection_conclusion: ${{ steps.detection_conclusion.outputs.conclusion }}
+ detection_success: ${{ steps.detection_conclusion.outputs.success }}
+ steps:
+ - name: Checkout actions folder
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ repository: github/gh-aw
+ sparse-checkout: |
+ actions
+ persist-credentials: false
+ - name: Setup Scripts
+ uses: ./actions/setup
+ with:
+ destination: ${{ runner.temp }}/gh-aw/actions
+ - name: Download agent output artifact
+ id: download-agent-output
+ continue-on-error: true
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+ with:
+ name: agent
+ path: /tmp/gh-aw/
+ - name: Setup agent output environment variable
+ id: setup-agent-output-env
+ if: steps.download-agent-output.outcome == 'success'
+ run: |
+ mkdir -p /tmp/gh-aw/
+ find "/tmp/gh-aw/" -type f -print
+ echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT"
+ # --- Threat Detection ---
+ - name: Download container images
+ run: bash ${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh ghcr.io/github/gh-aw-firewall/agent:0.25.3 ghcr.io/github/gh-aw-firewall/api-proxy:0.25.3 ghcr.io/github/gh-aw-firewall/squid:0.25.3
+ - name: Check if detection needed
+ id: detection_guard
+ if: always()
+ env:
+ OUTPUT_TYPES: ${{ needs.agent.outputs.output_types }}
+ HAS_PATCH: ${{ needs.agent.outputs.has_patch }}
+ run: |
+ if [[ -n "$OUTPUT_TYPES" || "$HAS_PATCH" == "true" ]]; then
+ echo "run_detection=true" >> "$GITHUB_OUTPUT"
+ echo "Detection will run: output_types=$OUTPUT_TYPES, has_patch=$HAS_PATCH"
+ else
+ echo "run_detection=false" >> "$GITHUB_OUTPUT"
+ echo "Detection skipped: no agent outputs or patches to analyze"
+ fi
+ - name: Clear MCP configuration for detection
+ if: always() && steps.detection_guard.outputs.run_detection == 'true'
+ run: |
+ rm -f /tmp/gh-aw/mcp-config/mcp-servers.json
+ rm -f /home/runner/.copilot/mcp-config.json
+ rm -f "$GITHUB_WORKSPACE/.gemini/settings.json"
+ - name: Prepare threat detection files
+ if: always() && steps.detection_guard.outputs.run_detection == 'true'
+ run: |
+ mkdir -p /tmp/gh-aw/threat-detection/aw-prompts
+ cp /tmp/gh-aw/aw-prompts/prompt.txt /tmp/gh-aw/threat-detection/aw-prompts/prompt.txt 2>/dev/null || true
+ cp /tmp/gh-aw/agent_output.json /tmp/gh-aw/threat-detection/agent_output.json 2>/dev/null || true
+ for f in /tmp/gh-aw/aw-*.patch; do
+ [ -f "$f" ] && cp "$f" /tmp/gh-aw/threat-detection/ 2>/dev/null || true
+ done
+ for f in /tmp/gh-aw/aw-*.bundle; do
+ [ -f "$f" ] && cp "$f" /tmp/gh-aw/threat-detection/ 2>/dev/null || true
+ done
+ echo "Prepared threat detection files:"
+ ls -la /tmp/gh-aw/threat-detection/ 2>/dev/null || true
+ - name: Setup threat detection
+ if: always() && steps.detection_guard.outputs.run_detection == 'true'
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
+ env:
+ WORKFLOW_NAME: "APM JavaScript Mirror Monitor"
+ WORKFLOW_DESCRIPTION: "Daily monitor that checks the microsoft/APM Python source (packer.py, unpacker.py) for changes and ensures apm_pack.cjs and apm_unpack.cjs stay in sync; creates a PR when updates are needed"
+ HAS_PATCH: ${{ needs.agent.outputs.has_patch }}
+ 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/setup_threat_detection.cjs');
+ await main();
+ - name: Ensure threat-detection directory and log
+ if: always() && steps.detection_guard.outputs.run_detection == 'true'
+ run: |
+ mkdir -p /tmp/gh-aw/threat-detection
+ touch /tmp/gh-aw/threat-detection/detection.log
+ - name: Setup Node.js
+ uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
+ with:
+ node-version: '24'
+ package-manager-cache: false
+ - name: Install AWF binary
+ run: bash ${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh v0.25.3
+ - name: Install Claude Code CLI
+ run: npm install -g @anthropic-ai/claude-code@latest
+ - name: Execute Claude Code CLI
+ if: always() && steps.detection_guard.outputs.run_detection == 'true'
+ id: detection_agentic_execution
+ # Allowed tools (sorted):
+ # - Bash
+ # - BashOutput
+ # - ExitPlanMode
+ # - Glob
+ # - Grep
+ # - KillBash
+ # - LS
+ # - NotebookRead
+ # - Read
+ # - Task
+ # - TodoWrite
+ timeout-minutes: 20
+ run: |
+ set -o pipefail
+ touch /tmp/gh-aw/agent-step-summary.md
+ # shellcheck disable=SC1003
+ sudo -E awf --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" --tty --env-all --exclude-env ANTHROPIC_API_KEY --allow-domains '*.githubusercontent.com,anthropic.com,api.anthropic.com,api.github.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,cdn.playwright.dev,codeload.github.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,files.pythonhosted.org,ghcr.io,github-cloud.githubusercontent.com,github-cloud.s3.amazonaws.com,github.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,lfs.github.com,objects.githubusercontent.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,playwright.download.prss.microsoft.com,ppa.launchpad.net,pypi.org,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,sentry.io,statsig.anthropic.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com' --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --audit-dir /tmp/gh-aw/sandbox/firewall/audit --enable-host-access --image-tag 0.25.3 --skip-pull --enable-api-proxy \
+ -- /bin/bash -c 'export PATH="$(find /opt/hostedtoolcache -maxdepth 4 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true && claude --print --disable-slash-commands --no-chrome --allowed-tools Bash,BashOutput,ExitPlanMode,Glob,Grep,KillBash,LS,NotebookRead,Read,Task,TodoWrite --debug-file /tmp/gh-aw/threat-detection/detection.log --verbose --permission-mode bypassPermissions --output-format stream-json "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"${GH_AW_MODEL_DETECTION_CLAUDE:+ --model "$GH_AW_MODEL_DETECTION_CLAUDE"}' 2>&1 | tee -a /tmp/gh-aw/threat-detection/detection.log
+ env:
+ ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
+ BASH_DEFAULT_TIMEOUT_MS: 60000
+ BASH_MAX_TIMEOUT_MS: 60000
+ DISABLE_BUG_COMMAND: 1
+ DISABLE_ERROR_REPORTING: 1
+ DISABLE_TELEMETRY: 1
+ GH_AW_MODEL_DETECTION_CLAUDE: ${{ vars.GH_AW_MODEL_DETECTION_CLAUDE || '' }}
+ GH_AW_PHASE: detection
+ GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt
+ GH_AW_VERSION: dev
+ GITHUB_AW: true
+ GITHUB_STEP_SUMMARY: /tmp/gh-aw/agent-step-summary.md
+ GITHUB_WORKSPACE: ${{ github.workspace }}
+ GIT_AUTHOR_EMAIL: github-actions[bot]@users.noreply.github.com
+ GIT_AUTHOR_NAME: github-actions[bot]
+ GIT_COMMITTER_EMAIL: github-actions[bot]@users.noreply.github.com
+ GIT_COMMITTER_NAME: github-actions[bot]
+ MCP_TIMEOUT: 120000
+ MCP_TOOL_TIMEOUT: 60000
+ - name: Upload threat detection log
+ if: always() && steps.detection_guard.outputs.run_detection == 'true'
+ uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
+ with:
+ name: detection
+ path: /tmp/gh-aw/threat-detection/detection.log
+ if-no-files-found: ignore
+ - name: Parse and conclude threat detection
+ id: detection_conclusion
+ if: always()
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
+ env:
+ RUN_DETECTION: ${{ steps.detection_guard.outputs.run_detection }}
+ 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/parse_threat_detection_results.cjs');
+ await main();
+
+ pre_activation:
+ runs-on: ubuntu-slim
+ permissions:
+ contents: read
+ outputs:
+ activated: ${{ steps.check_membership.outputs.is_team_member == 'true' && steps.check_skip_if_match.outputs.skip_check_ok == 'true' }}
+ matched_command: ''
+ steps:
+ - name: Checkout actions folder
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ repository: github/gh-aw
+ sparse-checkout: |
+ actions
+ persist-credentials: false
+ - name: Setup Scripts
+ uses: ./actions/setup
+ with:
+ destination: ${{ runner.temp }}/gh-aw/actions
+ - name: Check team membership for workflow
+ id: check_membership
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
+ env:
+ GH_AW_REQUIRED_ROLES: "admin,maintainer,write"
+ with:
+ github-token: ${{ secrets.GITHUB_TOKEN }}
+ 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/check_membership.cjs');
+ await main();
+ - name: Check skip-if-match query
+ id: check_skip_if_match
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
+ env:
+ GH_AW_SKIP_QUERY: "is:pr is:open in:title \"[apm-js-mirror]\""
+ GH_AW_WORKFLOW_NAME: "APM JavaScript Mirror Monitor"
+ GH_AW_SKIP_MAX_MATCHES: "1"
+ 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/check_skip_if_match.cjs');
+ await main();
+
+ safe_outputs:
+ needs:
+ - activation
+ - agent
+ - detection
+ if: (!cancelled()) && needs.agent.result != 'skipped' && needs.detection.result == 'success'
+ runs-on: ubuntu-slim
+ permissions:
+ contents: write
+ issues: write
+ pull-requests: write
+ timeout-minutes: 15
+ env:
+ GH_AW_CALLER_WORKFLOW_ID: "${{ github.repository }}/apm-js-mirror-monitor"
+ GH_AW_ENGINE_ID: "claude"
+ GH_AW_ENGINE_MODEL: ${{ needs.agent.outputs.model }}
+ GH_AW_TRACKER_ID: "apm-js-mirror-monitor"
+ GH_AW_WORKFLOW_ID: "apm-js-mirror-monitor"
+ GH_AW_WORKFLOW_NAME: "APM JavaScript Mirror Monitor"
+ outputs:
+ code_push_failure_count: ${{ steps.process_safe_outputs.outputs.code_push_failure_count }}
+ code_push_failure_errors: ${{ steps.process_safe_outputs.outputs.code_push_failure_errors }}
+ create_discussion_error_count: ${{ steps.process_safe_outputs.outputs.create_discussion_error_count }}
+ create_discussion_errors: ${{ steps.process_safe_outputs.outputs.create_discussion_errors }}
+ created_issue_number: ${{ steps.process_safe_outputs.outputs.created_issue_number }}
+ created_issue_url: ${{ steps.process_safe_outputs.outputs.created_issue_url }}
+ created_pr_number: ${{ steps.process_safe_outputs.outputs.created_pr_number }}
+ created_pr_url: ${{ steps.process_safe_outputs.outputs.created_pr_url }}
+ process_safe_outputs_processed_count: ${{ steps.process_safe_outputs.outputs.processed_count }}
+ process_safe_outputs_temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }}
+ steps:
+ - name: Checkout actions folder
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ repository: github/gh-aw
+ sparse-checkout: |
+ actions
+ persist-credentials: false
+ - name: Setup Scripts
+ uses: ./actions/setup
+ with:
+ destination: ${{ runner.temp }}/gh-aw/actions
+ - name: Download agent output artifact
+ id: download-agent-output
+ continue-on-error: true
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+ with:
+ name: agent
+ path: /tmp/gh-aw/
+ - name: Setup agent output environment variable
+ id: setup-agent-output-env
+ if: steps.download-agent-output.outcome == 'success'
+ run: |
+ mkdir -p /tmp/gh-aw/
+ find "/tmp/gh-aw/" -type f -print
+ echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT"
+ - name: Download patch artifact
+ continue-on-error: true
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+ with:
+ name: agent
+ path: /tmp/gh-aw/
+ - name: Checkout repository
+ if: (!cancelled()) && needs.agent.result != 'skipped' && contains(needs.agent.outputs.output_types, 'create_pull_request')
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ ref: ${{ github.base_ref || github.event.pull_request.base.ref || github.ref_name || github.event.repository.default_branch }}
+ token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
+ persist-credentials: false
+ fetch-depth: 1
+ - name: Configure Git credentials
+ if: (!cancelled()) && needs.agent.result != 'skipped' && contains(needs.agent.outputs.output_types, 'create_pull_request')
+ env:
+ REPO_NAME: ${{ github.repository }}
+ SERVER_URL: ${{ github.server_url }}
+ GIT_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
+ run: |
+ git config --global user.email "github-actions[bot]@users.noreply.github.com"
+ git config --global user.name "github-actions[bot]"
+ git config --global am.keepcr true
+ # Re-authenticate git with GitHub token
+ SERVER_URL_STRIPPED="${SERVER_URL#https://}"
+ git remote set-url origin "https://x-access-token:${GIT_TOKEN}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git"
+ echo "Git configured with standard GitHub Actions identity"
+ - 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: Process Safe Outputs
+ id: process_safe_outputs
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
+ env:
+ GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }}
+ GH_AW_ALLOWED_DOMAINS: "*.githubusercontent.com,anthropic.com,api.anthropic.com,api.github.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,cdn.playwright.dev,codeload.github.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,docs.github.com,files.pythonhosted.org,ghcr.io,github-cloud.githubusercontent.com,github-cloud.s3.amazonaws.com,github.blog,github.com,github.githubassets.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,lfs.github.com,objects.githubusercontent.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,playwright.download.prss.microsoft.com,ppa.launchpad.net,pypi.org,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,sentry.io,statsig.anthropic.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com"
+ GITHUB_SERVER_URL: ${{ github.server_url }}
+ GITHUB_API_URL: ${{ github.api_url }}
+ GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"create_issue\":{\"expires\":72,\"labels\":[\"automation\",\"dependencies\",\"apm\"],\"max\":1,\"title_prefix\":\"[apm-js-mirror] \"},\"create_pull_request\":{\"expires\":72,\"labels\":[\"automation\",\"dependencies\",\"apm\"],\"max\":1,\"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/\"],\"reviewers\":[\"copilot\"],\"title_prefix\":\"[apm-js-mirror] \"},\"missing_data\":{},\"missing_tool\":{},\"noop\":{\"max\":1,\"report-as-issue\":\"true\"}}"
+ GH_AW_CI_TRIGGER_TOKEN: ${{ secrets.GH_AW_CI_TRIGGER_TOKEN }}
+ with:
+ github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
+ 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/safe_output_handler_manager.cjs');
+ await main();
+ - name: Upload Safe Output Items
+ if: always()
+ uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
+ with:
+ name: safe-output-items
+ path: /tmp/gh-aw/safe-output-items.jsonl
+ if-no-files-found: ignore
+
+ update_cache_memory:
+ needs:
+ - agent
+ - detection
+ if: always() && (needs.detection.result == 'success' || needs.detection.result == 'skipped')
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ env:
+ GH_AW_WORKFLOW_ID_SANITIZED: apmjsmirrormonitor
+ steps:
+ - name: Checkout actions folder
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ repository: github/gh-aw
+ sparse-checkout: |
+ actions
+ persist-credentials: false
+ - name: Setup Scripts
+ uses: ./actions/setup
+ with:
+ destination: ${{ runner.temp }}/gh-aw/actions
+ - name: Download cache-memory artifact (default)
+ id: download_cache_default
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+ continue-on-error: true
+ with:
+ name: cache-memory
+ path: /tmp/gh-aw/cache-memory
+ - name: Check if cache-memory folder has content (default)
+ id: check_cache_default
+ shell: bash
+ run: |
+ if [ -d "/tmp/gh-aw/cache-memory" ] && [ "$(ls -A /tmp/gh-aw/cache-memory 2>/dev/null)" ]; then
+ echo "has_content=true" >> "$GITHUB_OUTPUT"
+ else
+ echo "has_content=false" >> "$GITHUB_OUTPUT"
+ fi
+ - name: Save cache-memory to cache (default)
+ if: steps.check_cache_default.outputs.has_content == 'true'
+ uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
+ with:
+ key: memory-${{ env.GH_AW_WORKFLOW_ID_SANITIZED }}-${{ github.run_id }}
+ path: /tmp/gh-aw/cache-memory
+
diff --git a/.github/workflows/apm-js-mirror-monitor.md b/.github/workflows/apm-js-mirror-monitor.md
new file mode 100644
index 00000000000..a551abe536b
--- /dev/null
+++ b/.github/workflows/apm-js-mirror-monitor.md
@@ -0,0 +1,269 @@
+---
+description: Daily monitor that checks the microsoft/APM Python source (packer.py, unpacker.py) for changes and ensures apm_pack.cjs and apm_unpack.cjs stay in sync; creates a PR when updates are needed
+on:
+ schedule: daily
+ workflow_dispatch:
+ skip-if-match: 'is:pr is:open in:title "[apm-js-mirror]"'
+permissions:
+ contents: read
+ pull-requests: read
+ issues: read
+tracker-id: apm-js-mirror-monitor
+engine: claude
+strict: false
+network:
+ allowed:
+ - defaults
+ - github
+ - "api.github.com"
+ - "raw.githubusercontent.com"
+tools:
+ cache-memory: true
+ web-fetch:
+ github:
+ toolsets: [repos, pull_requests]
+ bash:
+ - "*"
+ edit:
+safe-outputs:
+ create-pull-request:
+ title-prefix: "[apm-js-mirror] "
+ labels: [automation, dependencies, apm]
+ reviewers: [copilot]
+ expires: 3d
+ create-issue:
+ expires: 3d
+ title-prefix: "[apm-js-mirror] "
+ labels: [automation, dependencies, apm]
+ noop:
+timeout-minutes: 30
+---
+
+# APM JavaScript Mirror Monitor
+
+You are an expert JavaScript developer who maintains the gh-aw JavaScript reimplementations of the `microsoft/APM` Python package. Your job is to watch for changes to the upstream Python source and update the JS files when needed.
+
+## Current Context
+
+- **Repository**: ${{ github.repository }}
+- **Run**: ${{ github.run_id }}
+- **Date**: ${{ github.run_id }}
+
+## Background
+
+gh-aw maintains two JavaScript files that mirror the Python implementations in [microsoft/APM](https://github.com/microsoft/APM):
+
+| JS file (in `actions/setup/js/`) | Python source (in `microsoft/APM`) | Purpose |
+|---|---|---|
+| `apm_unpack.cjs` | `src/apm/unpacker.py` | Extracts and deploys APM bundles |
+| `apm_pack.cjs` | `src/apm/packer.py` + `src/apm/lockfile_enrichment.py` | Packs workspace into a `.tar.gz` bundle |
+
+The JS files must stay functionally equivalent to their Python counterparts. Critical areas to keep in sync:
+- `TARGET_PREFIXES` map (target → deployed-file path prefixes)
+- `CROSS_TARGET_MAPS` map (cross-target path equivalences)
+- Pack/unpack algorithm steps and security checks
+- Lockfile YAML format (`apm.lock.yaml` structure)
+- New fields added to `LockedDependency`
+
+## Phase 1: Check Cache and Decide Whether to Proceed
+
+**Read cache-memory first** at `/tmp/gh-aw/cache-memory/apm-js-mirror/`:
+
+```bash
+ls /tmp/gh-aw/cache-memory/apm-js-mirror/ 2>/dev/null || echo "No cache found"
+cat /tmp/gh-aw/cache-memory/apm-js-mirror/state.json 2>/dev/null || echo "No state found"
+```
+
+The state file tracks:
+- `last_checked_at` — ISO timestamp of last check
+- `packer_sha` — last known commit SHA of `src/apm/packer.py`
+- `unpacker_sha` — last known commit SHA of `src/apm/unpacker.py`
+- `enrichment_sha` — last known commit SHA of `src/apm/lockfile_enrichment.py`
+- `apm_version` — last known APM release version
+- `js_in_sync` — boolean, whether JS files were in sync at last check
+
+**If** the cache shows a check within the last 20 hours AND `js_in_sync` is `true`, verify by quickly comparing the stored SHAs against current upstream. If unchanged, save a new timestamp and exit with noop.
+
+## Phase 2: Fetch Upstream Python Source
+
+Fetch the Python source files from the `microsoft/APM` repository using web-fetch.
+
+### 2.1 Get latest release version and commit SHAs
+
+```bash
+# Fetch latest APM release
+curl -s "https://api.github.com/repos/microsoft/APM/releases/latest" \
+ -H "Accept: application/vnd.github.v3+json"
+```
+
+Also fetch the commit history for each Python file to get its latest SHA:
+
+```bash
+# Latest commit for each relevant file
+curl -s "https://api.github.com/repos/microsoft/APM/commits?path=src/apm/packer.py&per_page=1" \
+ -H "Accept: application/vnd.github.v3+json"
+curl -s "https://api.github.com/repos/microsoft/APM/commits?path=src/apm/unpacker.py&per_page=1" \
+ -H "Accept: application/vnd.github.v3+json"
+curl -s "https://api.github.com/repos/microsoft/APM/commits?path=src/apm/lockfile_enrichment.py&per_page=1" \
+ -H "Accept: application/vnd.github.v3+json"
+```
+
+### 2.2 Compare SHAs with cached values
+
+If all three SHAs match the cached values and the cache is recent, there are no upstream changes. Save updated timestamp and exit with noop.
+
+### 2.3 Fetch Python source content
+
+If SHAs differ (or no cache), fetch the full source:
+
+Use web-fetch to retrieve:
+1. `https://raw.githubusercontent.com/microsoft/APM/main/src/apm/packer.py`
+2. `https://raw.githubusercontent.com/microsoft/APM/main/src/apm/unpacker.py`
+3. `https://raw.githubusercontent.com/microsoft/APM/main/src/apm/lockfile_enrichment.py`
+
+Save them locally for analysis:
+
+```bash
+mkdir -p /tmp/apm-upstream
+# Save fetched content to:
+# /tmp/apm-upstream/packer.py
+# /tmp/apm-upstream/unpacker.py
+# /tmp/apm-upstream/lockfile_enrichment.py
+```
+
+## Phase 3: Analyze Differences
+
+### 3.1 Read the current JS files
+
+```bash
+cat actions/setup/js/apm_pack.cjs
+cat actions/setup/js/apm_unpack.cjs
+```
+
+### 3.2 Compare TARGET_PREFIXES
+
+In `lockfile_enrichment.py`, look for the `TARGET_PREFIXES` dict (or equivalent constant). Compare with `TARGET_PREFIXES` in `apm_pack.cjs`. Flag any differences.
+
+### 3.3 Compare CROSS_TARGET_MAPS
+
+In `lockfile_enrichment.py`, look for the cross-target mapping (maps like `{".github/skills/": ".claude/skills/", ...}` per target). Compare with `CROSS_TARGET_MAPS` in `apm_pack.cjs`. Flag any differences.
+
+### 3.4 Compare pack algorithm steps
+
+In `packer.py`, look for `pack_bundle()` or equivalent. Compare the algorithm steps with `packBundle()` in `apm_pack.cjs`:
+1. Read apm.yml for name/version
+2. Read apm.lock.yaml
+3. Detect target
+4. Filter deployed_files by target
+5. Verify files exist
+6. Copy files (skip symlinks)
+7. Write enriched lockfile with pack: header
+8. Create tar.gz archive
+
+Note any new steps or changed semantics.
+
+### 3.5 Compare unpack algorithm steps
+
+In `unpacker.py`, look for `unpack_bundle()` or equivalent. Compare with `unpackBundle()` in `apm_unpack.cjs`:
+1. Find tar.gz bundle
+2. Extract to temp directory
+3. Find inner bundle directory
+4. Read lockfile
+5. Collect deployed_files
+6. Verify bundle completeness
+7. Copy files to output directory
+8. Clean up temp directory
+
+Note any new steps or changed semantics.
+
+### 3.6 Compare LockedDependency fields
+
+In `unpacker.py` or a shared model file, find the fields of the lock file dependency object. Compare with the `LockedDependency` typedef in `apm_unpack.cjs`. Flag any new or removed fields.
+
+### 3.7 Compare lockfile YAML format
+
+Look for changes in how PyYAML serializes the lockfile (field order, quoting conventions). Compare with `serializeLockfileYaml()` in `apm_pack.cjs`.
+
+## Phase 4: Produce Updates or Report
+
+### Case A: No functional differences
+
+If the analysis finds only cosmetic differences (comments, whitespace, variable names) with no functional impact:
+
+1. Update cache with new SHAs and `js_in_sync: true`
+2. Exit with noop:
+
+```json
+{"noop": {"message": "APM JS mirror is up to date. Checked packer.py (SHA: ), unpacker.py (SHA: ), lockfile_enrichment.py (SHA: ). No functional differences found."}}
+```
+
+### Case B: Functional differences found — create a PR
+
+When the analysis identifies functional differences that require JS updates:
+
+1. **Make the changes** to `actions/setup/js/apm_pack.cjs` and/or `actions/setup/js/apm_unpack.cjs` to mirror the upstream Python changes.
+ - Update `TARGET_PREFIXES` if target mappings changed
+ - Update `CROSS_TARGET_MAPS` if cross-target mappings changed
+ - Update algorithm steps if pack/unpack logic changed
+ - Add/remove `LockedDependency` fields if the lockfile schema changed
+ - Update `serializeLockfileYaml()` if lockfile format changed
+ - Update `parseAPMLockfile()` if the YAML parser needs changes
+
+2. **Run the JS tests** to verify nothing is broken:
+ ```bash
+ cd actions/setup/js && npm ci --silent && npx vitest run --no-file-parallelism apm_pack.test.cjs apm_unpack.test.cjs
+ ```
+ If tests fail, update them to reflect the new behavior (the tests should match the Python reference behavior).
+
+3. **Format** the modified files:
+ ```bash
+ cd actions/setup/js && npx prettier --write 'apm_pack.cjs' 'apm_unpack.cjs' 'apm_pack.test.cjs' 'apm_unpack.test.cjs' --ignore-path ../../../.prettierignore
+ ```
+
+4. Update the cache state with new SHAs and `js_in_sync: true`.
+
+5. Create a pull request with all modified files. The PR description must include:
+ - Which Python files changed (with links to the commits)
+ - What functional differences were found
+ - What was updated in the JS files
+ - Test results
+
+### Case C: Breaking changes that cannot be auto-fixed
+
+If the Python source has changed in a way that is too complex to automatically mirror (e.g., major algorithmic refactor, new external dependencies):
+
+1. Update cache with new SHAs and `js_in_sync: false`.
+
+2. Create an issue describing:
+ - What changed in the upstream Python source
+ - What needs to be updated in the JS files
+ - Suggested approach for the manual update
+
+## Cache State
+
+Save updated state after every run (success or failure):
+
+```bash
+mkdir -p /tmp/gh-aw/cache-memory/apm-js-mirror
+cat > /tmp/gh-aw/cache-memory/apm-js-mirror/state.json << EOF
+{
+ "last_checked_at": "$(date -u +%Y-%m-%dT%H-%M-%S-000Z)",
+ "packer_sha": "",
+ "unpacker_sha": "",
+ "enrichment_sha": "",
+ "apm_version": "",
+ "js_in_sync": true
+}
+EOF
+```
+
+**Note on timestamps**: Use `YYYY-MM-DDTHH-MM-SS-mmmZ` format (hyphens instead of colons) to comply with filesystem naming restrictions for cache-memory artifact uploads.
+
+## Guidelines
+
+- Always check cache first to avoid redundant upstream API calls
+- Only create a PR if there are genuine functional differences
+- Keep JS files functionally equivalent but not necessarily structurally identical to Python
+- Preserve JSDoc comments and the existing code style
+- Never remove security checks (path-traversal, symlink skipping, boundary checks)
+- If the Python source is unreachable, save error to cache and exit with noop
From 74421475a20ba86b6484f64f58c75d36246be6f3 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 29 Mar 2026 12:24:24 +0000
Subject: [PATCH 04/12] fix: address all code review comments from copilot
reviewer
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- CROSS_TARGET_MAPS JSDoc: fix direction comment (src→dst, not bundle→disk)
- detectTarget(): use lstatSync().isDirectory() for .github/.claude consistently
with .cursor/.opencode checks to reject plain files with those names
- serializeLockfileYaml(): run all string scalar fields (repo_url, host,
resolved_commit, resolved_ref, virtual_path, resolved_by, package_type,
source, local_path, content_hash, deployed_files items, packMeta.format/target)
through scalarToYaml() to prevent YAML keyword/number misinterpretation
- LockedDependency.extra: add extra:{} field to preserve unknown dep fields
(e.g. is_local) during parse/serialize round-trip so pack is non-destructive
- test rename: 'returns empty array when no files match target' →
'applies cross-target mapping when no direct-match files exist'
- apm_dependencies.go: replace echo with quoted heredoc (<<'APM_YAML') to
prevent shell injection from package names containing single quotes/metacharacters
- apm-js-mirror-monitor.md: remove duplicate Date line that echoed run_id twice
Agent-Logs-Url: https://github.com/github/gh-aw/sessions/b34de4d8-a161-4feb-a73d-a5e163933aa7
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
---
.github/workflows/apm-js-mirror-monitor.md | 1 -
.github/workflows/smoke-claude.lock.yml | 9 +++--
actions/setup/js/apm_pack.cjs | 39 ++++++++++++----------
actions/setup/js/apm_pack.test.cjs | 2 +-
actions/setup/js/apm_unpack.cjs | 5 ++-
pkg/workflow/apm_dependencies.go | 19 +++++++----
6 files changed, 47 insertions(+), 28 deletions(-)
diff --git a/.github/workflows/apm-js-mirror-monitor.md b/.github/workflows/apm-js-mirror-monitor.md
index a551abe536b..2ef73ae415f 100644
--- a/.github/workflows/apm-js-mirror-monitor.md
+++ b/.github/workflows/apm-js-mirror-monitor.md
@@ -47,7 +47,6 @@ You are an expert JavaScript developer who maintains the gh-aw JavaScript reimpl
- **Repository**: ${{ github.repository }}
- **Run**: ${{ github.run_id }}
-- **Date**: ${{ github.run_id }}
## Background
diff --git a/.github/workflows/smoke-claude.lock.yml b/.github/workflows/smoke-claude.lock.yml
index eb611d81add..71cd91021a1 100644
--- a/.github/workflows/smoke-claude.lock.yml
+++ b/.github/workflows/smoke-claude.lock.yml
@@ -2311,8 +2311,13 @@ jobs:
pip install --quiet "apm-cli==${APM_VERSION}"
mkdir -p /tmp/gh-aw/apm-workspace
cd /tmp/gh-aw/apm-workspace
- printf 'name: gh-aw-workspace\nversion: 0.0.0\ndependencies:\n apm:\n' > apm.yml
- echo ' - microsoft/apm-sample-package' >> apm.yml
+ cat <<'APM_YAML' > apm.yml
+ name: gh-aw-workspace
+ version: 0.0.0
+ dependencies:
+ apm:
+ - microsoft/apm-sample-package
+ APM_YAML
apm install
- name: Pack APM bundle
id: apm_pack
diff --git a/actions/setup/js/apm_pack.cjs b/actions/setup/js/apm_pack.cjs
index 6b3b1d1f648..2806e37909a 100644
--- a/actions/setup/js/apm_pack.cjs
+++ b/actions/setup/js/apm_pack.cjs
@@ -69,8 +69,9 @@ const TARGET_PREFIXES = {
/**
* Cross-target path equivalences for skills/ and agents/ directories.
- * Maps bundle_path_prefix → disk_path_prefix for a given target.
- * Only skills/ and agents/ are semantically identical across targets.
+ * Maps srcPrefix (disk/deployed_files path) → dstPrefix (bundle path) for a given target,
+ * as used by filterFilesByTarget(). Only skills/ and agents/ are semantically identical
+ * across targets.
* @type {Record>}
*/
const CROSS_TARGET_MAPS = {
@@ -161,8 +162,8 @@ function detectTarget(workspaceDir, explicitTarget) {
}
// Auto-detect from folder structure
- const hasGitHub = fs.existsSync(path.join(workspaceDir, ".github"));
- const hasClaude = fs.existsSync(path.join(workspaceDir, ".claude"));
+ const hasGitHub = fs.existsSync(path.join(workspaceDir, ".github")) && fs.lstatSync(path.join(workspaceDir, ".github")).isDirectory();
+ const hasClaude = fs.existsSync(path.join(workspaceDir, ".claude")) && fs.lstatSync(path.join(workspaceDir, ".claude")).isDirectory();
const hasCursor = fs.existsSync(path.join(workspaceDir, ".cursor")) && fs.lstatSync(path.join(workspaceDir, ".cursor")).isDirectory();
const hasOpencode = fs.existsSync(path.join(workspaceDir, ".opencode")) && fs.lstatSync(path.join(workspaceDir, ".opencode")).isDirectory();
@@ -307,8 +308,8 @@ function serializeLockfileYaml(lockfile, filteredDeps, packMeta) {
// Pack metadata section (prepended first, as in Python's enrich_lockfile_for_pack)
lines.push("pack:");
- lines.push(` format: ${packMeta.format}`);
- lines.push(` target: ${packMeta.target}`);
+ lines.push(` format: ${scalarToYaml(packMeta.format)}`);
+ lines.push(` target: ${scalarToYaml(packMeta.target)}`);
lines.push(` packed_at: ${scalarToYaml(packMeta.packed_at)}`);
if (packMeta.mapped_from && packMeta.mapped_from.length > 0) {
lines.push(" mapped_from:");
@@ -331,33 +332,37 @@ function serializeLockfileYaml(lockfile, filteredDeps, packMeta) {
// Dependencies sequence
lines.push("dependencies:");
for (const dep of filteredDeps) {
- lines.push(`- repo_url: ${dep.repo_url}`);
- if (dep.host !== null) lines.push(` host: ${dep.host}`);
+ lines.push(`- repo_url: ${scalarToYaml(dep.repo_url)}`);
+ if (dep.host !== null) lines.push(` host: ${scalarToYaml(dep.host)}`);
else lines.push(` host: null`);
- if (dep.resolved_commit !== null) lines.push(` resolved_commit: ${dep.resolved_commit}`);
+ if (dep.resolved_commit !== null) lines.push(` resolved_commit: ${scalarToYaml(dep.resolved_commit)}`);
else lines.push(` resolved_commit: null`);
- if (dep.resolved_ref !== null) lines.push(` resolved_ref: ${dep.resolved_ref}`);
+ if (dep.resolved_ref !== null) lines.push(` resolved_ref: ${scalarToYaml(dep.resolved_ref)}`);
else lines.push(` resolved_ref: null`);
if (dep.version !== null) lines.push(` version: ${scalarToYaml(dep.version)}`);
else lines.push(` version: null`);
- if (dep.virtual_path !== null) lines.push(` virtual_path: ${dep.virtual_path}`);
+ if (dep.virtual_path !== null) lines.push(` virtual_path: ${scalarToYaml(dep.virtual_path)}`);
else lines.push(` virtual_path: null`);
lines.push(` is_virtual: ${dep.is_virtual ? "true" : "false"}`);
lines.push(` depth: ${dep.depth}`);
- if (dep.resolved_by !== null) lines.push(` resolved_by: ${dep.resolved_by}`);
+ if (dep.resolved_by !== null) lines.push(` resolved_by: ${scalarToYaml(dep.resolved_by)}`);
else lines.push(` resolved_by: null`);
- if (dep.package_type !== null) lines.push(` package_type: ${dep.package_type}`);
+ if (dep.package_type !== null) lines.push(` package_type: ${scalarToYaml(dep.package_type)}`);
else lines.push(` package_type: null`);
- if (dep.source !== null) lines.push(` source: ${dep.source}`);
+ if (dep.source !== null) lines.push(` source: ${scalarToYaml(dep.source)}`);
else lines.push(` source: null`);
- if (dep.local_path !== null) lines.push(` local_path: ${dep.local_path}`);
+ if (dep.local_path !== null) lines.push(` local_path: ${scalarToYaml(dep.local_path)}`);
else lines.push(` local_path: null`);
- if (dep.content_hash !== null) lines.push(` content_hash: ${dep.content_hash}`);
+ if (dep.content_hash !== null) lines.push(` content_hash: ${scalarToYaml(dep.content_hash)}`);
else lines.push(` content_hash: null`);
lines.push(` is_dev: ${dep.is_dev ? "true" : "false"}`);
+ // Preserve unknown fields so the enriched lockfile is non-destructive
+ for (const [k, v] of Object.entries(dep.extra || {})) {
+ lines.push(` ${k}: ${scalarToYaml(v)}`);
+ }
lines.push(" deployed_files:");
for (const f of dep.deployed_files) {
- lines.push(` - ${f}`);
+ lines.push(` - ${scalarToYaml(f)}`);
}
}
diff --git a/actions/setup/js/apm_pack.test.cjs b/actions/setup/js/apm_pack.test.cjs
index 0efa8bd8994..f5e32082b1d 100644
--- a/actions/setup/js/apm_pack.test.cjs
+++ b/actions/setup/js/apm_pack.test.cjs
@@ -211,7 +211,7 @@ describe("filterFilesByTarget – direct matches", () => {
expect(result).toContain(".cursor/rules/x.md");
});
- it("returns empty array when no files match target", () => {
+ it("applies cross-target mapping when no direct-match files exist", () => {
const files = [".github/skills/foo/"];
const { files: result } = filterFilesByTarget(files, "claude");
// No direct matches, but .github/skills/ → .claude/skills/ cross-map applies
diff --git a/actions/setup/js/apm_unpack.cjs b/actions/setup/js/apm_unpack.cjs
index 360e0f574f0..72643c36f4e 100644
--- a/actions/setup/js/apm_unpack.cjs
+++ b/actions/setup/js/apm_unpack.cjs
@@ -87,6 +87,7 @@ function unquoteYaml(raw) {
* @property {string | null} local_path
* @property {string | null} content_hash
* @property {boolean} is_dev
+ * @property {Record} extra - Unknown fields preserved for non-destructive round-trip
*/
/**
@@ -302,6 +303,7 @@ function makeEmptyDep() {
local_path: null,
content_hash: null,
is_dev: false,
+ extra: {},
};
}
@@ -356,7 +358,8 @@ function assignDepField(dep, key, value) {
dep.is_dev = value === true || value === "true";
break;
default:
- // Unknown field – ignore silently
+ // Unknown field – preserve in extra for non-destructive round-trip
+ dep.extra[key] = value;
break;
}
}
diff --git a/pkg/workflow/apm_dependencies.go b/pkg/workflow/apm_dependencies.go
index 700ee982ab6..78b9f8a7c2c 100644
--- a/pkg/workflow/apm_dependencies.go
+++ b/pkg/workflow/apm_dependencies.go
@@ -177,7 +177,9 @@ func GenerateAPMPackStep(apmDeps *APMDependenciesInfo, target string, data *Work
}
// Shell script: install apm-cli (strip leading 'v' from version), create workspace,
- // write apm.yml with the declared packages, and run apm install.
+ // write apm.yml with the declared packages using a heredoc with a quoted delimiter
+ // (<<'APM_YAML') so the shell never expands or interprets any characters inside the
+ // heredoc body, preventing injection from malicious package names.
lines = append(lines,
" run: |",
" set -e",
@@ -185,14 +187,19 @@ func GenerateAPMPackStep(apmDeps *APMDependenciesInfo, target string, data *Work
" pip install --quiet \"apm-cli==${APM_VERSION}\"",
" mkdir -p /tmp/gh-aw/apm-workspace",
" cd /tmp/gh-aw/apm-workspace",
- " printf 'name: gh-aw-workspace\\nversion: 0.0.0\\ndependencies:\\n apm:\\n' > apm.yml",
+ " cat <<'APM_YAML' > apm.yml",
+ " name: gh-aw-workspace",
+ " version: 0.0.0",
+ " dependencies:",
+ " apm:",
)
for _, dep := range apmDeps.Packages {
- // Use echo instead of printf to avoid printf interpreting dep as a format string
- // (package names with '%' characters would cause unexpected behavior with printf).
- lines = append(lines, fmt.Sprintf(" echo ' - %s' >> apm.yml", dep))
+ lines = append(lines, fmt.Sprintf(" - %s", dep))
}
- lines = append(lines, " apm install")
+ lines = append(lines,
+ " APM_YAML",
+ " apm install",
+ )
// -----------------------------------------------------------------------
// Step 2: Pack the installed workspace with the JavaScript implementation
From d391154ae4f05974a59ab9345a3cda5d9fba7d5c Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 29 Mar 2026 12:28:02 +0000
Subject: [PATCH 05/12] fix: store path.join results in variables to avoid
redundant computation in detectTarget()
Agent-Logs-Url: https://github.com/github/gh-aw/sessions/b34de4d8-a161-4feb-a73d-a5e163933aa7
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
---
actions/setup/js/apm_pack.cjs | 12 ++++++++----
1 file changed, 8 insertions(+), 4 deletions(-)
diff --git a/actions/setup/js/apm_pack.cjs b/actions/setup/js/apm_pack.cjs
index 2806e37909a..94fdb090049 100644
--- a/actions/setup/js/apm_pack.cjs
+++ b/actions/setup/js/apm_pack.cjs
@@ -162,10 +162,14 @@ function detectTarget(workspaceDir, explicitTarget) {
}
// Auto-detect from folder structure
- const hasGitHub = fs.existsSync(path.join(workspaceDir, ".github")) && fs.lstatSync(path.join(workspaceDir, ".github")).isDirectory();
- const hasClaude = fs.existsSync(path.join(workspaceDir, ".claude")) && fs.lstatSync(path.join(workspaceDir, ".claude")).isDirectory();
- const hasCursor = fs.existsSync(path.join(workspaceDir, ".cursor")) && fs.lstatSync(path.join(workspaceDir, ".cursor")).isDirectory();
- const hasOpencode = fs.existsSync(path.join(workspaceDir, ".opencode")) && fs.lstatSync(path.join(workspaceDir, ".opencode")).isDirectory();
+ const githubDir = path.join(workspaceDir, ".github");
+ const claudeDir = path.join(workspaceDir, ".claude");
+ const cursorDir = path.join(workspaceDir, ".cursor");
+ const opencodeDir = path.join(workspaceDir, ".opencode");
+ const hasGitHub = fs.existsSync(githubDir) && fs.lstatSync(githubDir).isDirectory();
+ const hasClaude = fs.existsSync(claudeDir) && fs.lstatSync(claudeDir).isDirectory();
+ const hasCursor = fs.existsSync(cursorDir) && fs.lstatSync(cursorDir).isDirectory();
+ const hasOpencode = fs.existsSync(opencodeDir) && fs.lstatSync(opencodeDir).isDirectory();
const detected = [hasGitHub && ".github/", hasClaude && ".claude/", hasCursor && ".cursor/", hasOpencode && ".opencode/"].filter(Boolean);
From 58732d22e8ee5c2652ce3e748d3b7afbe0f183f8 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 29 Mar 2026 13:08:19 +0000
Subject: [PATCH 06/12] feat: remove apm-cli dependency, reimplement apm
install in JavaScript
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Replaces the two-step approach (shell `pip install apm-cli` + `apm install`,
then github-script `apm_pack.cjs`) with a single self-contained github-script
step that runs both install and pack using pure JavaScript.
New files:
- actions/setup/js/apm_install.cjs — downloads APM packages from GitHub
using the REST API (GITHUB_APM_PAT via @actions/github Octokit), writes
deployed files to workspace, writes apm.yml + apm.lock.yaml
- actions/setup/js/run_apm_install.cjs — standalone runner for CI / local dev
- actions/setup/js/apm_install.test.cjs — 28 unit tests with mocked Octokit
Updated:
- pkg/workflow/apm_dependencies.go — GenerateAPMPackStep emits a single
github-script step; packages passed as JSON via APM_PACKAGES env var
(single-quoted in YAML to avoid array-literal parsing)
- pkg/workflow/apm_dependencies_test.go — assertions updated for new step
- .github/workflows/ci.yml — APM integration job uses JS-only round-trip
(no Python, no pip install); renamed to js-apm-integration
- .github/workflows/smoke-claude.lock.yml — recompiled
Agent-Logs-Url: https://github.com/github/gh-aw/sessions/2645f721-40b5-482e-8768-5b57590b4470
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
---
.github/workflows/ci.yml | 148 ++------
.github/workflows/smoke-claude.lock.yml | 27 +-
actions/setup/js/apm_install.cjs | 484 ++++++++++++++++++++++++
actions/setup/js/apm_install.test.cjs | 419 ++++++++++++++++++++
actions/setup/js/run_apm_install.cjs | 47 +++
pkg/workflow/apm_dependencies.go | 106 +++---
pkg/workflow/apm_dependencies_test.go | 33 +-
7 files changed, 1061 insertions(+), 203 deletions(-)
create mode 100644 actions/setup/js/apm_install.cjs
create mode 100644 actions/setup/js/apm_install.test.cjs
create mode 100644 actions/setup/js/run_apm_install.cjs
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 5bfbe57c02a..49811d87583 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -953,28 +953,20 @@ jobs:
echo "✨ Live API test completed successfully" >> $GITHUB_STEP_SUMMARY
fi
- js-apm-unpack-integration:
- name: APM Pack/Unpack Integration (Python vs JS)
+ js-apm-integration:
+ name: APM JS Pack/Unpack Integration
runs-on: ubuntu-latest
timeout-minutes: 15
needs: validate-yaml
permissions:
contents: read
concurrency:
- group: ci-${{ github.ref }}-js-apm-unpack-integration
+ group: ci-${{ github.ref }}-js-apm-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
@@ -1000,6 +992,8 @@ jobs:
APMEOF
# apm.lock.yaml — two dependencies, mixed files and a directory entry
+ # (apm_install.cjs would normally produce this from real GitHub repos;
+ # here we create it manually so the CI test runs without GitHub API access)
cat > apm.lock.yaml << 'APMEOF'
lockfile_version: '1'
apm_version: '0.8.5'
@@ -1034,16 +1028,6 @@ jobs:
echo "✅ APM test project created at $APM_PROJECT"
find "$APM_PROJECT" -type f | sort
- - name: Pack APM bundle with Python (reference)
- run: |
- set -e
- cd /tmp/apm-test-project
- mkdir -p /tmp/apm-bundle-python
- apm pack --archive -o /tmp/apm-bundle-python
- echo ""
- echo "✅ Python bundle created:"
- ls -lh /tmp/apm-bundle-python/*.tar.gz
-
- name: Pack APM bundle with JavaScript (apm_pack.cjs)
env:
APM_WORKSPACE: /tmp/apm-test-project
@@ -1057,39 +1041,7 @@ jobs:
echo "✅ JavaScript bundle created:"
ls -lh /tmp/apm-bundle-js/*.tar.gz
- - name: Unpack Python bundle with Python (reference)
- run: |
- set -e
- mkdir -p /tmp/apm-out-py-py
- BUNDLE=$(ls /tmp/apm-bundle-python/*.tar.gz)
- apm unpack "$BUNDLE" -o /tmp/apm-out-py-py
- echo ""
- echo "=== Python pack + Python unpack ==="
- find /tmp/apm-out-py-py -type f | sort
-
- - name: Unpack Python bundle with JavaScript (apm_unpack.cjs)
- env:
- APM_BUNDLE_DIR: /tmp/apm-bundle-python
- OUTPUT_DIR: /tmp/apm-out-py-js
- run: |
- set -e
- mkdir -p /tmp/apm-out-py-js
- node actions/setup/js/run_apm_unpack.cjs
- echo ""
- echo "=== Python pack + JavaScript unpack ==="
- find /tmp/apm-out-py-js -type f | sort
-
- - name: Unpack JavaScript bundle with Python (cross-implementation check)
- run: |
- set -e
- mkdir -p /tmp/apm-out-js-py
- BUNDLE=$(ls /tmp/apm-bundle-js/*.tar.gz)
- apm unpack "$BUNDLE" -o /tmp/apm-out-js-py
- echo ""
- echo "=== JavaScript pack + Python unpack ==="
- find /tmp/apm-out-js-py -type f | sort
-
- - name: Unpack JavaScript bundle with JavaScript
+ - name: Unpack JavaScript bundle with JavaScript (apm_unpack.cjs)
env:
APM_BUNDLE_DIR: /tmp/apm-bundle-js
OUTPUT_DIR: /tmp/apm-out-js-js
@@ -1101,71 +1053,55 @@ jobs:
echo "=== JavaScript pack + JavaScript unpack ==="
find /tmp/apm-out-js-js -type f | sort
- - name: Compare all pack/unpack combinations against reference
+ - name: Verify JS pack/unpack round-trip output
run: |
set -e
- echo "## APM Pack/Unpack Integration Test" >> $GITHUB_STEP_SUMMARY
+ echo "## APM JS Pack/Unpack Integration Test" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
- # Reference: Python pack + Python unpack
- echo "### Reference: Python pack + Python unpack" >> $GITHUB_STEP_SUMMARY
- echo '```' >> $GITHUB_STEP_SUMMARY
- find /tmp/apm-out-py-py -type f | sort | sed "s|/tmp/apm-out-py-py/||" >> $GITHUB_STEP_SUMMARY
- echo '```' >> $GITHUB_STEP_SUMMARY
-
PASS=true
- # 1. Python pack + JavaScript unpack (pre-existing test)
- echo "### Python pack + JavaScript unpack" >> $GITHUB_STEP_SUMMARY
- if diff -rq /tmp/apm-out-py-py /tmp/apm-out-py-js > /tmp/diff1.txt 2>&1; then
- echo "✅ Identical to reference" >> $GITHUB_STEP_SUMMARY
- echo "✅ [1/3] Python pack + JS unpack: identical to reference"
- else
- echo "❌ Differs from reference" >> $GITHUB_STEP_SUMMARY
- echo '```diff' >> $GITHUB_STEP_SUMMARY
- diff -r /tmp/apm-out-py-py /tmp/apm-out-py-js >> $GITHUB_STEP_SUMMARY 2>&1 || true
- echo '```' >> $GITHUB_STEP_SUMMARY
- echo "❌ [1/3] Python pack + JS unpack differs from reference:"
- cat /tmp/diff1.txt
- PASS=false
- fi
+ check_file() {
+ local file="$1"
+ local expected_content="$2"
+ if [ -f "/tmp/apm-out-js-js/$file" ]; then
+ if [ -n "$expected_content" ]; then
+ actual=$(cat "/tmp/apm-out-js-js/$file")
+ if echo "$actual" | grep -qF "$expected_content"; then
+ echo "✅ $file — content ok" >> $GITHUB_STEP_SUMMARY
+ else
+ echo "❌ $file — unexpected content" >> $GITHUB_STEP_SUMMARY
+ echo " expected: $expected_content" >> $GITHUB_STEP_SUMMARY
+ echo " actual: $actual" >> $GITHUB_STEP_SUMMARY
+ PASS=false
+ fi
+ else
+ echo "✅ $file — present" >> $GITHUB_STEP_SUMMARY
+ fi
+ else
+ echo "❌ $file — missing" >> $GITHUB_STEP_SUMMARY
+ PASS=false
+ fi
+ }
- # 2. JavaScript pack + Python unpack (validates JS packer output)
- echo "### JavaScript pack + Python unpack" >> $GITHUB_STEP_SUMMARY
- if diff -rq /tmp/apm-out-py-py /tmp/apm-out-js-py > /tmp/diff2.txt 2>&1; then
- echo "✅ Identical to reference" >> $GITHUB_STEP_SUMMARY
- echo "✅ [2/3] JS pack + Python unpack: identical to reference"
- else
- echo "❌ Differs from reference" >> $GITHUB_STEP_SUMMARY
- echo '```diff' >> $GITHUB_STEP_SUMMARY
- diff -r /tmp/apm-out-py-py /tmp/apm-out-js-py >> $GITHUB_STEP_SUMMARY 2>&1 || true
- echo '```' >> $GITHUB_STEP_SUMMARY
- echo "❌ [2/3] JS pack + Python unpack differs from reference:"
- cat /tmp/diff2.txt
- PASS=false
- fi
+ check_file ".github/skills/skill-a/skill.md" "Skill A"
+ check_file ".github/skills/skill-a/notes.txt" "Skill A helper notes"
+ check_file ".github/copilot-instructions.md" "Copilot Instructions"
+ check_file ".github/skills/skill-b/skill.md" "Skill B"
+ check_file ".github/agents.md" "Agent configuration"
- # 3. JavaScript pack + JavaScript unpack (full JS round-trip)
- echo "### JavaScript pack + JavaScript unpack" >> $GITHUB_STEP_SUMMARY
- if diff -rq /tmp/apm-out-py-py /tmp/apm-out-js-js > /tmp/diff3.txt 2>&1; then
- echo "✅ Identical to reference" >> $GITHUB_STEP_SUMMARY
- echo "✅ [3/3] JS pack + JS unpack: identical to reference"
- else
- echo "❌ Differs from reference" >> $GITHUB_STEP_SUMMARY
- echo '```diff' >> $GITHUB_STEP_SUMMARY
- diff -r /tmp/apm-out-py-py /tmp/apm-out-js-js >> $GITHUB_STEP_SUMMARY 2>&1 || true
- echo '```' >> $GITHUB_STEP_SUMMARY
- echo "❌ [3/3] JS pack + JS unpack differs from reference:"
- cat /tmp/diff3.txt
- PASS=false
- fi
+ echo "" >> $GITHUB_STEP_SUMMARY
+ echo "### Files in output:" >> $GITHUB_STEP_SUMMARY
+ echo '```' >> $GITHUB_STEP_SUMMARY
+ find /tmp/apm-out-js-js -type f | sort | sed "s|/tmp/apm-out-js-js/||" >> $GITHUB_STEP_SUMMARY
+ echo '```' >> $GITHUB_STEP_SUMMARY
if [ "$PASS" = "true" ]; then
echo "" >> $GITHUB_STEP_SUMMARY
- echo "### ✅ All 3 comparisons passed" >> $GITHUB_STEP_SUMMARY
+ echo "### ✅ JS pack/unpack round-trip passed" >> $GITHUB_STEP_SUMMARY
else
echo "" >> $GITHUB_STEP_SUMMARY
- echo "### ❌ One or more comparisons failed" >> $GITHUB_STEP_SUMMARY
+ echo "### ❌ JS pack/unpack round-trip failed" >> $GITHUB_STEP_SUMMARY
exit 1
fi
diff --git a/.github/workflows/smoke-claude.lock.yml b/.github/workflows/smoke-claude.lock.yml
index 71cd91021a1..38f4039ecf0 100644
--- a/.github/workflows/smoke-claude.lock.yml
+++ b/.github/workflows/smoke-claude.lock.yml
@@ -2302,27 +2302,12 @@ jobs:
env:
GH_AW_INFO_APM_VERSION: v0.8.6
steps:
- - name: Install APM CLI and packages
- env:
- GITHUB_APM_PAT: ${{ secrets.GH_AW_PLUGINS_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
- run: |
- set -e
- APM_VERSION="${GH_AW_INFO_APM_VERSION#v}"
- pip install --quiet "apm-cli==${APM_VERSION}"
- mkdir -p /tmp/gh-aw/apm-workspace
- cd /tmp/gh-aw/apm-workspace
- cat <<'APM_YAML' > apm.yml
- name: gh-aw-workspace
- version: 0.0.0
- dependencies:
- apm:
- - microsoft/apm-sample-package
- APM_YAML
- apm install
- - name: Pack APM bundle
+ - name: Install and pack APM bundle
id: apm_pack
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
env:
+ GITHUB_APM_PAT: ${{ secrets.GH_AW_PLUGINS_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
+ APM_PACKAGES: '["microsoft/apm-sample-package"]'
APM_WORKSPACE: /tmp/gh-aw/apm-workspace
APM_BUNDLE_OUTPUT: /tmp/gh-aw/apm-bundle-output
APM_TARGET: claude
@@ -2330,8 +2315,10 @@ jobs:
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_pack.cjs');
- await main();
+ const { main: apmInstall } = require('${{ runner.temp }}/gh-aw/actions/apm_install.cjs');
+ await apmInstall();
+ const { main: apmPack } = require('${{ runner.temp }}/gh-aw/actions/apm_pack.cjs');
+ await apmPack();
- name: Upload APM bundle artifact
if: success()
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
diff --git a/actions/setup/js/apm_install.cjs b/actions/setup/js/apm_install.cjs
new file mode 100644
index 00000000000..29bdb3666f5
--- /dev/null
+++ b/actions/setup/js/apm_install.cjs
@@ -0,0 +1,484 @@
+// @ts-check
+///
+
+/**
+ * APM Package Installer
+ *
+ * JavaScript reimplementation of `apm install`. Downloads APM packages
+ * from GitHub and creates the installed workspace used by `apm pack`.
+ *
+ * Algorithm:
+ * 1. Parse APM_PACKAGES (JSON array of package slugs) from the environment
+ * 2. For each package slug:
+ * a. Parse the slug: owner/repo[/subpath][#ref]
+ * b. Resolve the ref (branch/tag/SHA) to a full commit SHA
+ * c. Scan the repo tree recursively for deployable files
+ * - Full package (no subpath): files under .github/, .claude/, .cursor/, .opencode/
+ * - Individual primitive (with subpath): files under {target_dir}/{subpath}/
+ * d. Download each file and write to APM_WORKSPACE at its original path
+ * e. Record the resolved dependency in the lockfile
+ * 3. Write apm.yml (workspace metadata for the packer) to APM_WORKSPACE
+ * 4. Write apm.lock.yaml (resolved dependency manifest) to APM_WORKSPACE
+ *
+ * Environment variables:
+ * GITHUB_APM_PAT – GitHub token for API access (required for private repos;
+ * falls back to GITHUB_TOKEN if not set)
+ * APM_PACKAGES – JSON array of package slugs,
+ * e.g. '["microsoft/apm-sample-package","org/repo/skills/foo#v2"]'
+ * APM_WORKSPACE – destination directory for downloaded files + lockfile
+ * (default: /tmp/gh-aw/apm-workspace)
+ *
+ * @module apm_install
+ */
+
+"use strict";
+
+const fs = require("fs");
+const path = require("path");
+
+/** Lockfile filename written to the workspace. */
+const LOCKFILE_NAME = "apm.lock.yaml";
+
+/** apm.yml filename for workspace metadata (consumed by apm_pack). */
+const APM_YML_NAME = "apm.yml";
+
+/** Directories that contain deployable APM primitives. */
+const TARGET_DIRS = [".github/", ".claude/", ".cursor/", ".opencode/"];
+
+// ---------------------------------------------------------------------------
+// Package slug parser
+// ---------------------------------------------------------------------------
+
+/**
+ * @typedef {Object} PackageRef
+ * @property {string} owner - GitHub org / user
+ * @property {string} repo - Repository name
+ * @property {string | null} subpath - Path within the repo (e.g. "skills/foo"); null = full package
+ * @property {string | null} ref - Git ref (branch, tag, SHA); null = default branch
+ */
+
+/**
+ * Parse an APM package slug into its components.
+ *
+ * Formats:
+ * owner/repo
+ * owner/repo#ref
+ * owner/repo/path/to/primitive
+ * owner/repo/path/to/primitive#ref
+ *
+ * @param {string} slug
+ * @returns {PackageRef}
+ */
+function parsePackageSlug(slug) {
+ if (!slug || typeof slug !== "string") throw new Error(`Invalid package slug: ${JSON.stringify(slug)}`);
+
+ // Split off optional #ref suffix
+ const hashIdx = slug.indexOf("#");
+ const ref = hashIdx >= 0 ? slug.slice(hashIdx + 1) || null : null;
+ const pathPart = hashIdx >= 0 ? slug.slice(0, hashIdx) : slug;
+
+ const parts = pathPart.split("/");
+ if (parts.length < 2 || !parts[0] || !parts[1]) {
+ throw new Error(`Invalid package slug (expected owner/repo[/subpath][#ref]): ${JSON.stringify(slug)}`);
+ }
+
+ const owner = parts[0];
+ const repo = parts[1];
+ const subpath = parts.length > 2 ? parts.slice(2).join("/") : null;
+
+ return { owner, repo, subpath, ref };
+}
+
+// ---------------------------------------------------------------------------
+// YAML scalar serializer (inline copy — avoids cross-module circular dep)
+// ---------------------------------------------------------------------------
+
+/**
+ * Serialize a scalar value to YAML, quoting strings that YAML would misinterpret
+ * (e.g. keywords, numbers, ISO timestamps). Mirrors PyYAML safe_dump quoting.
+ *
+ * @param {string | number | boolean | null | undefined} value
+ * @returns {string}
+ */
+function scalarToYaml(value) {
+ if (value === null || value === undefined) return "null";
+ if (typeof value === "boolean") return value ? "true" : "false";
+ if (typeof value === "number") return String(value);
+ const s = String(value);
+ // YAML keywords and special patterns that must be quoted to preserve string type
+ const YAML_KEYWORDS = new Set(["", "null", "~", "true", "false", "yes", "no", "on", "off"]);
+ const NEEDS_QUOTING =
+ YAML_KEYWORDS.has(s) ||
+ /^-?\d+$/.test(s) || // integer
+ /^-?\d+\.\d+$/.test(s) || // float
+ /^\d{4}-\d{2}-\d{2}T/.test(s); // ISO 8601 datetime
+ if (NEEDS_QUOTING) {
+ return `'${s.replace(/'/g, "''")}'`;
+ }
+ return s;
+}
+
+// ---------------------------------------------------------------------------
+// Lockfile writer
+// ---------------------------------------------------------------------------
+
+/**
+ * @typedef {Object} InstalledDependency
+ * @property {string} repo_url
+ * @property {string} resolved_commit
+ * @property {string} resolved_ref
+ * @property {string[]} deployed_files
+ */
+
+/**
+ * Write the workspace apm.lock.yaml based on the resolved dependencies.
+ *
+ * @param {string} workspaceDir
+ * @param {InstalledDependency[]} dependencies
+ */
+function writeWorkspaceLockfile(workspaceDir, dependencies) {
+ const lines = [];
+ lines.push(`lockfile_version: ${scalarToYaml("1")}`);
+ lines.push(`generated_at: ${scalarToYaml(new Date().toISOString())}`);
+ lines.push("apm_version: null");
+ lines.push("dependencies:");
+
+ for (const dep of dependencies) {
+ lines.push(`- repo_url: ${scalarToYaml(dep.repo_url)}`);
+ lines.push(` host: github.com`);
+ lines.push(` resolved_commit: ${scalarToYaml(dep.resolved_commit)}`);
+ lines.push(` resolved_ref: ${scalarToYaml(dep.resolved_ref)}`);
+ lines.push(` version: null`);
+ lines.push(` virtual_path: null`);
+ lines.push(` is_virtual: false`);
+ lines.push(` depth: 1`);
+ lines.push(` resolved_by: apm_install.cjs`);
+ lines.push(` package_type: apm`);
+ lines.push(` source: null`);
+ lines.push(` local_path: null`);
+ lines.push(` content_hash: null`);
+ lines.push(` is_dev: false`);
+ lines.push(` deployed_files:`);
+ for (const f of dep.deployed_files) {
+ lines.push(` - ${scalarToYaml(f)}`);
+ }
+ }
+
+ const lockfileContent = lines.join("\n") + "\n";
+ fs.writeFileSync(path.join(workspaceDir, LOCKFILE_NAME), lockfileContent, "utf-8");
+}
+
+/**
+ * Write a minimal apm.yml to the workspace (consumed by apm_pack for bundle naming).
+ *
+ * @param {string} workspaceDir
+ */
+function writeWorkspaceApmYml(workspaceDir) {
+ const content = "name: gh-aw-workspace\nversion: 0.0.0\n";
+ fs.writeFileSync(path.join(workspaceDir, APM_YML_NAME), content, "utf-8");
+}
+
+// ---------------------------------------------------------------------------
+// GitHub API client factory
+// ---------------------------------------------------------------------------
+
+/**
+ * Create an authenticated Octokit client.
+ *
+ * Priority:
+ * 1. Custom token in GITHUB_APM_PAT (may differ from GITHUB_TOKEN for private repos)
+ * 2. GITHUB_TOKEN (workflow token, public repos + repos accessible to workflow)
+ *
+ * When running via actions/github-script, global.github is available but is
+ * authenticated with GITHUB_TOKEN. We create a dedicated instance with
+ * GITHUB_APM_PAT so private package repos are accessible.
+ *
+ * @param {string} token - GitHub PAT or workflow token
+ * @returns Octokit instance
+ */
+function createOctokit(token) {
+ // @actions/github is bundled with actions/github-script and always available
+ // in the github-script execution environment.
+ // @ts-ignore – dynamic require at runtime
+ const { getOctokit } = require("@actions/github");
+ return getOctokit(token);
+}
+
+// ---------------------------------------------------------------------------
+// GitHub REST helpers
+// ---------------------------------------------------------------------------
+
+/**
+ * Resolve a git ref (branch name, tag, SHA, etc.) to a full commit SHA.
+ * Returns the commit SHA and the effective ref string used.
+ *
+ * @param {*} octokit
+ * @param {string} owner
+ * @param {string} repo
+ * @param {string | null} ref
+ * @returns {Promise<{commitSha: string, resolvedRef: string, treeSha: string}>}
+ */
+async function resolveCommit(octokit, owner, repo, ref) {
+ let effectiveRef = ref;
+ if (!effectiveRef) {
+ const { data: repoData } = await octokit.rest.repos.get({ owner, repo });
+ effectiveRef = repoData.default_branch;
+ }
+
+ const { data: commitData } = await octokit.rest.repos.getCommit({
+ owner,
+ repo,
+ ref: effectiveRef,
+ });
+
+ return {
+ commitSha: commitData.sha,
+ resolvedRef: effectiveRef,
+ treeSha: commitData.commit.tree.sha,
+ };
+}
+
+/**
+ * Get the recursive file tree for a commit.
+ * Returns only blob (file) entries with their paths and blob SHAs.
+ *
+ * @param {*} octokit
+ * @param {string} owner
+ * @param {string} repo
+ * @param {string} treeSha
+ * @returns {Promise>}
+ */
+async function getFileTree(octokit, owner, repo, treeSha) {
+ const { data } = await octokit.rest.git.getTree({
+ owner,
+ repo,
+ tree_sha: treeSha,
+ recursive: "1",
+ });
+ const blobs = (data.tree || []).filter(entry => entry.type === "blob" && entry.path);
+ return blobs.map(entry => ({
+ path: /** @type {string} */ entry.path,
+ sha: entry.sha || "",
+ }));
+}
+
+/**
+ * Download raw file content from a GitHub repo at a specific commit SHA.
+ *
+ * @param {*} octokit
+ * @param {string} owner
+ * @param {string} repo
+ * @param {string} filePath
+ * @param {string} ref
+ * @returns {Promise}
+ */
+async function downloadFileContent(octokit, owner, repo, filePath, ref) {
+ const { data } = await octokit.rest.repos.getContent({
+ owner,
+ repo,
+ path: filePath,
+ ref,
+ });
+
+ // getContent returns different shapes; we only handle file blobs here
+ if (Array.isArray(data)) {
+ throw new Error(`Expected a file at '${filePath}' in ${owner}/${repo} but got a directory listing`);
+ }
+ if (!("content" in data) || !("encoding" in data)) {
+ throw new Error(`Unexpected content shape for '${filePath}' in ${owner}/${repo}`);
+ }
+
+ // Content is base64-encoded (the default encoding from the API)
+ const content = /** @type {{ content: string; encoding: string }} */ data;
+ if (content.encoding !== "base64") {
+ throw new Error(`Unexpected encoding '${content.encoding}' for '${filePath}'`);
+ }
+ return Buffer.from(content.content.replace(/\n/g, ""), "base64");
+}
+
+// ---------------------------------------------------------------------------
+// Package installation
+// ---------------------------------------------------------------------------
+
+/**
+ * Determine which files from a repo tree should be installed for a given package ref.
+ *
+ * - Full package (no subpath): all files under .github/, .claude/, .cursor/, .opencode/
+ * - Primitive subpath: files under {target_dir}/{subpath}/ for every target_dir
+ *
+ * @param {Array<{path: string, sha: string}>} tree
+ * @param {string | null} subpath
+ * @returns {Array<{path: string, sha: string}>}
+ */
+function selectDeployableFiles(tree, subpath) {
+ if (!subpath) {
+ // Full package — include all files under any known target directory
+ return tree.filter(entry => TARGET_DIRS.some(tdir => entry.path.startsWith(tdir)));
+ }
+
+ // Individual primitive — look for the subpath under every target directory,
+ // plus the subpath itself if files live directly at it (no target prefix)
+ const normalizedSubpath = subpath.endsWith("/") ? subpath : subpath + "/";
+ return tree.filter(entry => {
+ // Case 1: file already has a target dir prefix (common for APM package repos)
+ if (TARGET_DIRS.some(tdir => entry.path.startsWith(tdir + normalizedSubpath))) return true;
+ // Case 2: file is directly under the subpath (without target dir prefix)
+ if (entry.path.startsWith(normalizedSubpath)) return true;
+ // Case 3: exact match (e.g. subpath points to a single file)
+ if (entry.path === subpath) return true;
+ return false;
+ });
+}
+
+/**
+ * Install a single APM package into the workspace.
+ *
+ * @param {*} octokit - Authenticated Octokit instance
+ * @param {PackageRef} pkgRef - Parsed package reference
+ * @param {string} workspaceDir - Absolute path to workspace
+ * @returns {Promise}
+ */
+async function installPackage(octokit, pkgRef, workspaceDir) {
+ const { owner, repo, subpath, ref } = pkgRef;
+ const repoUrl = `https://github.com/${owner}/${repo}`;
+
+ core.info(`[APM Install] Installing ${owner}/${repo}${subpath ? `/${subpath}` : ""}${ref ? `#${ref}` : ""}`);
+
+ // Resolve ref → commit SHA + tree SHA
+ const { commitSha, resolvedRef, treeSha } = await resolveCommit(octokit, owner, repo, ref);
+ core.info(`[APM Install] ref: ${resolvedRef} → ${commitSha.slice(0, 12)}`);
+
+ // Get recursive file tree
+ const tree = await getFileTree(octokit, owner, repo, treeSha);
+ core.info(`[APM Install] repo tree: ${tree.length} blob(s)`);
+
+ // Filter to deployable files
+ const deployable = selectDeployableFiles(tree, subpath);
+ if (deployable.length === 0) {
+ core.warning(`[APM Install] No deployable files found in ${owner}/${repo}${subpath ? `/${subpath}` : ""}. ` + `Checked for files under ${TARGET_DIRS.join(", ")}${subpath ? `${subpath}/` : ""}.`);
+ }
+ core.info(`[APM Install] deployable: ${deployable.length} file(s)`);
+
+ // Download and write each file
+ const deployedFiles = [];
+ const workspaceDirResolved = path.resolve(workspaceDir);
+
+ for (let i = 0; i < deployable.length; i++) {
+ const entry = deployable[i];
+ const filePath = entry.path;
+
+ // Security: reject absolute paths and path traversal
+ if (path.isAbsolute(filePath) || filePath.includes("..")) {
+ core.warning(`[APM Install] Skipping unsafe path from ${owner}/${repo}: ${JSON.stringify(filePath)}`);
+ continue;
+ }
+
+ const destAbsPath = path.resolve(path.join(workspaceDir, filePath));
+ // Guard: destination must stay inside workspace
+ if (!destAbsPath.startsWith(workspaceDirResolved + path.sep) && destAbsPath !== workspaceDirResolved) {
+ core.warning(`[APM Install] Skipping path that escapes workspace: ${JSON.stringify(filePath)}`);
+ continue;
+ }
+
+ fs.mkdirSync(path.dirname(destAbsPath), { recursive: true });
+ const content = await downloadFileContent(octokit, owner, repo, filePath, commitSha);
+ fs.writeFileSync(destAbsPath, content);
+ deployedFiles.push(filePath);
+
+ if ((i + 1) % 10 === 0 || i + 1 === deployable.length) {
+ core.info(`[APM Install] progress: ${i + 1}/${deployable.length} downloaded`);
+ }
+ }
+
+ core.info(`[APM Install] ✓ ${owner}/${repo}: ${deployedFiles.length} file(s) installed`);
+
+ return {
+ repo_url: repoUrl,
+ resolved_commit: commitSha,
+ resolved_ref: resolvedRef,
+ deployed_files: deployedFiles,
+ };
+}
+
+// ---------------------------------------------------------------------------
+// Entry point
+// ---------------------------------------------------------------------------
+
+/**
+ * Main entry point.
+ *
+ * Accepts an optional options object for dependency injection (used in tests).
+ *
+ * @param {object} [opts]
+ * @param {*} [opts.octokitOverride] - Override Octokit client (for testing)
+ * @param {string} [opts.workspaceDir] - Override workspace directory (for testing)
+ * @param {string[]} [opts.packages] - Override packages list (for testing)
+ * @param {string} [opts.token] - Override auth token (for testing)
+ */
+async function main(opts = {}) {
+ const { octokitOverride = null, workspaceDir = process.env.APM_WORKSPACE || "/tmp/gh-aw/apm-workspace", packages = parsePackagesFromEnv(), token = process.env.GITHUB_APM_PAT || process.env.GITHUB_TOKEN || "" } = opts;
+
+ core.info("=== APM Package Install ===");
+ core.info(`[APM Install] Workspace directory: ${workspaceDir}`);
+ core.info(`[APM Install] Packages : ${packages.length}`);
+
+ if (packages.length === 0) {
+ core.warning("[APM Install] No packages to install (APM_PACKAGES is empty)");
+ fs.mkdirSync(workspaceDir, { recursive: true });
+ writeWorkspaceApmYml(workspaceDir);
+ writeWorkspaceLockfile(workspaceDir, []);
+ return;
+ }
+
+ const octokit = octokitOverride || createOctokit(token);
+
+ fs.mkdirSync(workspaceDir, { recursive: true });
+
+ /** @type {InstalledDependency[]} */
+ const dependencies = [];
+
+ for (const slug of packages) {
+ core.info(`[APM Install] ── ${slug}`);
+ const pkgRef = parsePackageSlug(slug);
+ const dep = await installPackage(octokit, pkgRef, workspaceDir);
+ dependencies.push(dep);
+ }
+
+ writeWorkspaceApmYml(workspaceDir);
+ writeWorkspaceLockfile(workspaceDir, dependencies);
+
+ core.info(`[APM Install] ✅ Installed ${dependencies.length} package(s)`);
+ core.info(`[APM Install] Workspace: ${workspaceDir}`);
+}
+
+/**
+ * Parse APM_PACKAGES env var into an array of package slug strings.
+ * Accepts a JSON array: '["owner/repo","owner/repo2"]'
+ *
+ * @returns {string[]}
+ */
+function parsePackagesFromEnv() {
+ const raw = process.env.APM_PACKAGES;
+ if (!raw || raw.trim() === "") return [];
+ try {
+ const parsed = JSON.parse(raw);
+ if (!Array.isArray(parsed)) throw new Error("APM_PACKAGES must be a JSON array");
+ return parsed.filter(p => typeof p === "string" && p.trim() !== "");
+ } catch (e) {
+ throw new Error(`[APM Install] Failed to parse APM_PACKAGES env var: ${e instanceof Error ? e.message : String(e)}\n` + ` Expected a JSON array, e.g.: ["owner/repo", "owner/repo/skills/foo#v2"]`);
+ }
+}
+
+module.exports = {
+ main,
+ parsePackageSlug,
+ selectDeployableFiles,
+ writeWorkspaceLockfile,
+ writeWorkspaceApmYml,
+ parsePackagesFromEnv,
+ // Exported for tests only
+ resolveCommit,
+ installPackage,
+ createOctokit,
+ scalarToYaml,
+};
diff --git a/actions/setup/js/apm_install.test.cjs b/actions/setup/js/apm_install.test.cjs
new file mode 100644
index 00000000000..590dc7106fb
--- /dev/null
+++ b/actions/setup/js/apm_install.test.cjs
@@ -0,0 +1,419 @@
+// @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 — must be done before requiring apm_install.cjs
+// ---------------------------------------------------------------------------
+
+const mockCore = {
+ info: vi.fn(),
+ warning: vi.fn(),
+ error: vi.fn(),
+ setFailed: vi.fn(),
+ setOutput: vi.fn(),
+};
+
+global.core = mockCore;
+
+// apm_install.cjs calls require('@actions/github') only inside createOctokit(),
+// which is only reached when octokitOverride is null. All unit tests inject
+// octokitOverride, so we never actually load @actions/github here.
+
+const { parsePackageSlug, selectDeployableFiles, writeWorkspaceLockfile, writeWorkspaceApmYml, parsePackagesFromEnv, scalarToYaml, main } = require("./apm_install.cjs");
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+/** Create a temp directory and return its path. */
+function makeTempDir() {
+ return fs.mkdtempSync(path.join(os.tmpdir(), "apm-install-test-"));
+}
+
+/** Remove a temp directory (best-effort). */
+function removeTempDir(dir) {
+ if (dir && fs.existsSync(dir)) {
+ fs.rmSync(dir, { recursive: true, force: true });
+ }
+}
+
+/** Build a minimal mock Octokit that returns the given tree and content map. */
+function makeMockOctokit({ defaultBranch = "main", commitSha = "aabbcc00", treeSha = "tree00", tree = [], contentMap = {} } = {}) {
+ return {
+ rest: {
+ repos: {
+ get: vi.fn().mockResolvedValue({ data: { default_branch: defaultBranch } }),
+ getCommit: vi.fn().mockResolvedValue({
+ data: { sha: commitSha, commit: { tree: { sha: treeSha } } },
+ }),
+ getContent: vi.fn().mockImplementation(({ path: filePath }) => {
+ const text = contentMap[filePath];
+ if (text === undefined) {
+ return Promise.reject(Object.assign(new Error("Not Found"), { status: 404 }));
+ }
+ return Promise.resolve({
+ data: {
+ type: "file",
+ encoding: "base64",
+ content: Buffer.from(text).toString("base64"),
+ },
+ });
+ }),
+ },
+ git: {
+ getTree: vi.fn().mockResolvedValue({ data: { tree } }),
+ },
+ },
+ };
+}
+
+// ---------------------------------------------------------------------------
+// parsePackageSlug
+// ---------------------------------------------------------------------------
+
+describe("parsePackageSlug", () => {
+ it("parses owner/repo", () => {
+ expect(parsePackageSlug("microsoft/apm-sample-package")).toEqual({
+ owner: "microsoft",
+ repo: "apm-sample-package",
+ subpath: null,
+ ref: null,
+ });
+ });
+
+ it("parses owner/repo#ref", () => {
+ expect(parsePackageSlug("microsoft/apm-sample-package#v2.0")).toEqual({
+ owner: "microsoft",
+ repo: "apm-sample-package",
+ subpath: null,
+ ref: "v2.0",
+ });
+ });
+
+ it("parses owner/repo/subpath", () => {
+ expect(parsePackageSlug("github/awesome-copilot/skills/review-and-refactor")).toEqual({
+ owner: "github",
+ repo: "awesome-copilot",
+ subpath: "skills/review-and-refactor",
+ ref: null,
+ });
+ });
+
+ it("parses owner/repo/subpath#ref", () => {
+ expect(parsePackageSlug("org/repo/skills/foo#main")).toEqual({
+ owner: "org",
+ repo: "repo",
+ subpath: "skills/foo",
+ ref: "main",
+ });
+ });
+
+ it("handles a commit SHA as ref", () => {
+ const { ref } = parsePackageSlug("org/repo#abc123def456");
+ expect(ref).toBe("abc123def456");
+ });
+
+ it("throws for slug without slash", () => {
+ expect(() => parsePackageSlug("just-one-part")).toThrow(/Invalid package slug/);
+ });
+
+ it("throws for empty string", () => {
+ expect(() => parsePackageSlug("")).toThrow(/Invalid package slug/);
+ });
+
+ it("throws for missing repo component", () => {
+ expect(() => parsePackageSlug("owner/")).toThrow(/Invalid package slug/);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// selectDeployableFiles – full package (no subpath)
+// ---------------------------------------------------------------------------
+
+describe("selectDeployableFiles – full package", () => {
+ /** @type {Array<{path: string, sha: string}>} */
+ const tree = [
+ { path: ".github/skills/foo/skill.md", sha: "a1" },
+ { path: ".github/agents/bar.md", sha: "a2" },
+ { path: ".claude/skills/foo/skill.md", sha: "a3" },
+ { path: ".cursor/rules/style.md", sha: "a4" },
+ { path: ".opencode/instructions.md", sha: "a5" },
+ { path: "README.md", sha: "a6" },
+ { path: "apm.yml", sha: "a7" },
+ ];
+
+ it("includes files under all target dirs", () => {
+ const result = selectDeployableFiles(tree, null).map(e => e.path);
+ expect(result).toContain(".github/skills/foo/skill.md");
+ expect(result).toContain(".github/agents/bar.md");
+ expect(result).toContain(".claude/skills/foo/skill.md");
+ expect(result).toContain(".cursor/rules/style.md");
+ expect(result).toContain(".opencode/instructions.md");
+ });
+
+ it("excludes non-target files", () => {
+ const result = selectDeployableFiles(tree, null).map(e => e.path);
+ expect(result).not.toContain("README.md");
+ expect(result).not.toContain("apm.yml");
+ });
+});
+
+// ---------------------------------------------------------------------------
+// selectDeployableFiles – individual primitive (with subpath)
+// ---------------------------------------------------------------------------
+
+describe("selectDeployableFiles – primitive subpath", () => {
+ /** @type {Array<{path: string, sha: string}>} */
+ const tree = [
+ { path: ".github/skills/review-and-refactor/skill.md", sha: "b1" },
+ { path: ".github/skills/review-and-refactor/notes.txt", sha: "b2" },
+ { path: ".claude/skills/review-and-refactor/skill.md", sha: "b3" },
+ { path: ".github/skills/other-skill/skill.md", sha: "b4" },
+ { path: ".github/agents/agent.md", sha: "b5" },
+ { path: "README.md", sha: "b6" },
+ ];
+
+ it("selects files under the subpath in all target dirs", () => {
+ const result = selectDeployableFiles(tree, "skills/review-and-refactor").map(e => e.path);
+ expect(result).toContain(".github/skills/review-and-refactor/skill.md");
+ expect(result).toContain(".github/skills/review-and-refactor/notes.txt");
+ expect(result).toContain(".claude/skills/review-and-refactor/skill.md");
+ });
+
+ it("excludes sibling skills", () => {
+ const result = selectDeployableFiles(tree, "skills/review-and-refactor").map(e => e.path);
+ expect(result).not.toContain(".github/skills/other-skill/skill.md");
+ });
+
+ it("excludes files from unrelated directories", () => {
+ const result = selectDeployableFiles(tree, "skills/review-and-refactor").map(e => e.path);
+ expect(result).not.toContain(".github/agents/agent.md");
+ expect(result).not.toContain("README.md");
+ });
+
+ it("returns empty array when no files match subpath", () => {
+ expect(selectDeployableFiles(tree, "skills/nonexistent")).toHaveLength(0);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// writeWorkspaceLockfile
+// ---------------------------------------------------------------------------
+
+describe("writeWorkspaceLockfile", () => {
+ /** @type {string} */
+ let tmpDir;
+
+ beforeEach(() => {
+ tmpDir = makeTempDir();
+ });
+ afterEach(() => removeTempDir(tmpDir));
+
+ it("writes lockfile_version, generated_at, dependencies", () => {
+ writeWorkspaceLockfile(tmpDir, []);
+ const content = fs.readFileSync(path.join(tmpDir, "apm.lock.yaml"), "utf-8");
+ expect(content).toContain("lockfile_version: '1'");
+ expect(content).toContain("generated_at:");
+ expect(content).toContain("dependencies:");
+ });
+
+ it("quotes ISO timestamp in generated_at so YAML parsers keep string type", () => {
+ writeWorkspaceLockfile(tmpDir, []);
+ const content = fs.readFileSync(path.join(tmpDir, "apm.lock.yaml"), "utf-8");
+ expect(content).toMatch(/generated_at: '\d{4}-\d{2}-\d{2}T/);
+ });
+
+ it("writes one dependency entry", () => {
+ writeWorkspaceLockfile(tmpDir, [
+ {
+ repo_url: "https://github.com/owner/pkg",
+ resolved_commit: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+ resolved_ref: "main",
+ deployed_files: [".github/skills/skill-a/skill.md"],
+ },
+ ]);
+ const content = fs.readFileSync(path.join(tmpDir, "apm.lock.yaml"), "utf-8");
+ expect(content).toContain("- repo_url: https://github.com/owner/pkg");
+ expect(content).toContain(" resolved_commit: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
+ expect(content).toContain(" resolved_ref: main");
+ expect(content).toContain(" - .github/skills/skill-a/skill.md");
+ });
+
+ it("round-trips through parseAPMLockfile from apm_unpack", () => {
+ const { parseAPMLockfile } = require("./apm_unpack.cjs");
+ writeWorkspaceLockfile(tmpDir, [
+ {
+ repo_url: "https://github.com/o/r",
+ resolved_commit: "deadbeef",
+ resolved_ref: "v1.0",
+ deployed_files: [".github/skills/foo/skill.md"],
+ },
+ ]);
+ const content = fs.readFileSync(path.join(tmpDir, "apm.lock.yaml"), "utf-8");
+ const parsed = parseAPMLockfile(content);
+ expect(parsed.lockfile_version).toBe("1");
+ expect(parsed.dependencies).toHaveLength(1);
+ expect(parsed.dependencies[0].repo_url).toBe("https://github.com/o/r");
+ expect(parsed.dependencies[0].resolved_commit).toBe("deadbeef");
+ expect(parsed.dependencies[0].deployed_files).toContain(".github/skills/foo/skill.md");
+ });
+});
+
+// ---------------------------------------------------------------------------
+// writeWorkspaceApmYml
+// ---------------------------------------------------------------------------
+
+describe("writeWorkspaceApmYml", () => {
+ /** @type {string} */
+ let tmpDir;
+
+ beforeEach(() => {
+ tmpDir = makeTempDir();
+ });
+ afterEach(() => removeTempDir(tmpDir));
+
+ it("writes apm.yml with name and version", () => {
+ writeWorkspaceApmYml(tmpDir);
+ const content = fs.readFileSync(path.join(tmpDir, "apm.yml"), "utf-8");
+ expect(content).toContain("name: gh-aw-workspace");
+ expect(content).toContain("version: 0.0.0");
+ });
+});
+
+// ---------------------------------------------------------------------------
+// parsePackagesFromEnv
+// ---------------------------------------------------------------------------
+
+describe("parsePackagesFromEnv", () => {
+ afterEach(() => {
+ delete process.env.APM_PACKAGES;
+ });
+
+ it("returns empty array when APM_PACKAGES is not set", () => {
+ delete process.env.APM_PACKAGES;
+ expect(parsePackagesFromEnv()).toEqual([]);
+ });
+
+ it("parses a JSON array of package slugs", () => {
+ process.env.APM_PACKAGES = '["microsoft/apm-sample-package","github/awesome-copilot/skills/foo"]';
+ expect(parsePackagesFromEnv()).toEqual(["microsoft/apm-sample-package", "github/awesome-copilot/skills/foo"]);
+ });
+
+ it("filters out empty strings", () => {
+ process.env.APM_PACKAGES = '["pkg-a","","pkg-b"]';
+ expect(parsePackagesFromEnv()).toEqual(["pkg-a", "pkg-b"]);
+ });
+
+ it("throws on non-array JSON", () => {
+ process.env.APM_PACKAGES = '"single-string"';
+ expect(() => parsePackagesFromEnv()).toThrow(/JSON array/);
+ });
+
+ it("throws on malformed JSON", () => {
+ process.env.APM_PACKAGES = "[not valid json";
+ expect(() => parsePackagesFromEnv()).toThrow(/parse APM_PACKAGES/);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// main() – mocked Octokit integration tests
+// ---------------------------------------------------------------------------
+
+describe("main() – mocked Octokit", () => {
+ /** @type {string} */
+ let tmpDir;
+
+ beforeEach(() => {
+ tmpDir = makeTempDir();
+ vi.clearAllMocks();
+ });
+ afterEach(() => {
+ removeTempDir(tmpDir);
+ delete process.env.APM_PACKAGES;
+ });
+
+ it("writes apm.yml and empty lockfile when packages list is empty", async () => {
+ await main({ octokitOverride: {}, workspaceDir: tmpDir, packages: [] });
+ expect(fs.existsSync(path.join(tmpDir, "apm.yml"))).toBe(true);
+ const lockContent = fs.readFileSync(path.join(tmpDir, "apm.lock.yaml"), "utf-8");
+ expect(lockContent).toContain("lockfile_version: '1'");
+ });
+
+ it("downloads files for a single package and writes lockfile", async () => {
+ const octokit = makeMockOctokit({
+ commitSha: "abcdef1234567890abcdef1234567890abcdef12",
+ tree: [
+ { type: "blob", path: ".github/skills/test-skill/skill.md", sha: "s1" },
+ { type: "blob", path: ".github/copilot-instructions.md", sha: "s2" },
+ { type: "blob", path: "README.md", sha: "s3" }, // non-target, should be skipped
+ ],
+ contentMap: {
+ ".github/skills/test-skill/skill.md": "# Test Skill",
+ ".github/copilot-instructions.md": "# Instructions",
+ },
+ });
+
+ await main({ octokitOverride: octokit, workspaceDir: tmpDir, packages: ["test-org/test-pkg"] });
+
+ expect(fs.existsSync(path.join(tmpDir, ".github/skills/test-skill/skill.md"))).toBe(true);
+ expect(fs.existsSync(path.join(tmpDir, ".github/copilot-instructions.md"))).toBe(true);
+ expect(fs.existsSync(path.join(tmpDir, "README.md"))).toBe(false);
+
+ const lockContent = fs.readFileSync(path.join(tmpDir, "apm.lock.yaml"), "utf-8");
+ expect(lockContent).toContain("https://github.com/test-org/test-pkg");
+ expect(lockContent).toContain("abcdef1234567890abcdef1234567890abcdef12");
+ expect(lockContent).toContain(".github/skills/test-skill/skill.md");
+ });
+
+ it("skips path-traversal entries from the GitHub tree", async () => {
+ const octokit = makeMockOctokit({
+ tree: [
+ { type: "blob", path: ".github/../../../etc/passwd", sha: "evil" },
+ { type: "blob", path: ".github/skills/safe/skill.md", sha: "safe" },
+ ],
+ contentMap: {
+ ".github/skills/safe/skill.md": "safe content",
+ },
+ });
+
+ await main({ octokitOverride: octokit, workspaceDir: tmpDir, packages: ["attacker/repo"] });
+
+ expect(fs.existsSync(path.join(tmpDir, ".github/skills/safe/skill.md"))).toBe(true);
+ expect(fs.existsSync(path.join(tmpDir, "etc/passwd"))).toBe(false);
+ });
+
+ it("lockfile is valid input for apm_pack (round-trip fixture)", async () => {
+ const { parseAPMLockfile } = require("./apm_unpack.cjs");
+
+ const octokit = makeMockOctokit({
+ commitSha: "cccc0000000000000000000000000000cccccccc",
+ tree: [
+ { type: "blob", path: ".github/skills/my-skill/skill.md", sha: "f1" },
+ { type: "blob", path: ".claude/agents/my-agent.md", sha: "f2" },
+ ],
+ contentMap: {
+ ".github/skills/my-skill/skill.md": "# My Skill",
+ ".claude/agents/my-agent.md": "# My Agent",
+ },
+ });
+
+ await main({ octokitOverride: octokit, workspaceDir: tmpDir, packages: ["my-org/my-pkg"] });
+
+ const lockContent = fs.readFileSync(path.join(tmpDir, "apm.lock.yaml"), "utf-8");
+ const parsed = parseAPMLockfile(lockContent);
+
+ expect(parsed.lockfile_version).toBe("1");
+ expect(parsed.dependencies).toHaveLength(1);
+ expect(parsed.dependencies[0].deployed_files).toContain(".github/skills/my-skill/skill.md");
+ expect(parsed.dependencies[0].deployed_files).toContain(".claude/agents/my-agent.md");
+
+ // Verify installed files are actually present on disk
+ expect(fs.existsSync(path.join(tmpDir, ".github/skills/my-skill/skill.md"))).toBe(true);
+ expect(fs.existsSync(path.join(tmpDir, ".claude/agents/my-agent.md"))).toBe(true);
+ });
+});
diff --git a/actions/setup/js/run_apm_install.cjs b/actions/setup/js/run_apm_install.cjs
new file mode 100644
index 00000000000..15289d41aef
--- /dev/null
+++ b/actions/setup/js/run_apm_install.cjs
@@ -0,0 +1,47 @@
+// @ts-check
+/**
+ * run_apm_install.cjs
+ *
+ * Standalone entry-point for apm_install.cjs used in CI integration tests
+ * and local development. Sets up lightweight CJS-compatible shims for the
+ * @actions/* globals expected by apm_install.cjs, then calls main().
+ *
+ * Environment variables (consumed by apm_install.main):
+ * GITHUB_APM_PAT – GitHub token (falls back to GITHUB_TOKEN)
+ * APM_PACKAGES – JSON array of package slugs
+ * APM_WORKSPACE – workspace directory for downloaded files + lockfile
+ *
+ * Usage:
+ * node actions/setup/js/run_apm_install.cjs
+ */
+
+"use strict";
+
+const { setupGlobals } = require("./setup_globals.cjs");
+const { main } = require("./apm_install.cjs");
+
+// Minimal shim for @actions/core — only the methods used by apm_install.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}`),
+};
+
+// Wire shims into globals so apm_install.cjs can use them.
+setupGlobals(
+ core, // logging
+ {}, // @actions/github — not used directly (apm_install creates its own Octokit)
+ {}, // GitHub Actions event context — not used
+ {}, // @actions/exec — not used
+ {} // @actions/io — not used
+);
+
+main().catch(err => {
+ console.error(`::error::${err.message}`);
+ process.exit(1);
+});
diff --git a/pkg/workflow/apm_dependencies.go b/pkg/workflow/apm_dependencies.go
index 78b9f8a7c2c..a06658a9551 100644
--- a/pkg/workflow/apm_dependencies.go
+++ b/pkg/workflow/apm_dependencies.go
@@ -1,6 +1,7 @@
package workflow
import (
+ "encoding/json"
"fmt"
"sort"
@@ -113,17 +114,18 @@ func buildAPMAppTokenInvalidationStep() []string {
return steps
}
-// GenerateAPMPackStep generates the GitHub Actions steps that install APM packages
-// and pack them into a bundle. This replaces the single microsoft/apm-action step
-// with two steps:
-// 1. A shell run: step that installs the apm-cli via pip and runs apm install
-// to resolve dependencies into the workspace directory.
-// 2. A github-script step (id: apm_pack) that runs apm_pack.cjs to create the
-// .tar.gz bundle and emit the bundle-path output.
+// GenerateAPMPackStep generates the GitHub Actions step that installs APM packages
+// from GitHub and packs them into a bundle in a single github-script step.
//
-// This eliminates the dependency on microsoft/apm-action for the pack phase.
-// The upload-artifact step in buildAPMJob references ${{ steps.apm_pack.outputs.bundle-path }},
-// so the github-script step must carry id: apm_pack.
+// The step uses two JavaScript modules from the gh-aw setup actions:
+// 1. apm_install.cjs — downloads packages from GitHub using the REST API,
+// writes the installed files and apm.lock.yaml to APM_WORKSPACE.
+// This replaces the previous `pip install apm-cli && apm install` shell step.
+// 2. apm_pack.cjs — reads the installed workspace, filters files by target,
+// creates the .tar.gz bundle, and emits the bundle-path output.
+//
+// The step id is "apm_pack" so the upload-artifact step can reference
+// ${{ steps.apm_pack.outputs.bundle-path }}.
//
// Parameters:
// - apmDeps: APM dependency configuration extracted from frontmatter
@@ -139,7 +141,7 @@ func GenerateAPMPackStep(apmDeps *APMDependenciesInfo, target string, data *Work
apmDepsLog.Printf("Generating APM pack step: %d packages, target=%s", len(apmDeps.Packages), target)
- // GITHUB_APM_PAT is the environment variable that the apm CLI uses for authentication.
+ // GITHUB_APM_PAT is the token used by apm_install.cjs for GitHub API access.
// When a GitHub App is configured, use the minted app token; otherwise use the
// cascading fallback token.
hasGitHubAppToken := apmDeps.GitHubApp != nil
@@ -151,24 +153,40 @@ func GenerateAPMPackStep(apmDeps *APMDependenciesInfo, target string, data *Work
githubAPMPatExpr = getEffectiveAPMGitHubToken(apmDeps.GitHubToken)
}
- // -----------------------------------------------------------------------
- // Step 1: Install apm-cli and run apm install to resolve packages
- // -----------------------------------------------------------------------
- var lines []string
- lines = append(lines,
- " - name: Install APM CLI and packages",
+ // Encode packages as a JSON array for the APM_PACKAGES env var.
+ // json.Marshal on a []string value never returns an error.
+ pkgsJSON, _ := json.Marshal(apmDeps.Packages)
+
+ githubScriptRef := GetActionPin("actions/github-script")
+
+ // Single github-script step: install packages from GitHub, then pack them.
+ lines := []string{
+ " - name: Install and pack APM bundle",
+ " id: apm_pack",
+ " uses: " + githubScriptRef,
" env:",
- " GITHUB_APM_PAT: "+githubAPMPatExpr,
- )
+ " GITHUB_APM_PAT: " + githubAPMPatExpr,
+ // APM_PACKAGES is a JSON array; single-quoting the value prevents YAML
+ // from parsing it as an array literal (GitHub Actions env values must be strings).
+ " APM_PACKAGES: '" + string(pkgsJSON) + "'",
+ " APM_WORKSPACE: /tmp/gh-aw/apm-workspace",
+ " APM_BUNDLE_OUTPUT: /tmp/gh-aw/apm-bundle-output",
+ " APM_TARGET: " + target,
+ }
- // Include any user-provided env vars (skip GITHUB_APM_PAT if already set above)
+ // Include any user-provided env vars (skip keys already set above)
+ reserved := map[string]bool{
+ "GITHUB_APM_PAT": true,
+ "APM_PACKAGES": true,
+ "APM_WORKSPACE": true,
+ "APM_TARGET": true,
+ }
if len(apmDeps.Env) > 0 {
keys := make([]string, 0, len(apmDeps.Env))
for k := range apmDeps.Env {
- if k == "GITHUB_APM_PAT" {
- continue // avoid duplicate key
+ if !reserved[k] {
+ keys = append(keys, k)
}
- keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
@@ -176,49 +194,15 @@ func GenerateAPMPackStep(apmDeps *APMDependenciesInfo, target string, data *Work
}
}
- // Shell script: install apm-cli (strip leading 'v' from version), create workspace,
- // write apm.yml with the declared packages using a heredoc with a quoted delimiter
- // (<<'APM_YAML') so the shell never expands or interprets any characters inside the
- // heredoc body, preventing injection from malicious package names.
lines = append(lines,
- " run: |",
- " set -e",
- " APM_VERSION=\"${GH_AW_INFO_APM_VERSION#v}\"",
- " pip install --quiet \"apm-cli==${APM_VERSION}\"",
- " mkdir -p /tmp/gh-aw/apm-workspace",
- " cd /tmp/gh-aw/apm-workspace",
- " cat <<'APM_YAML' > apm.yml",
- " name: gh-aw-workspace",
- " version: 0.0.0",
- " dependencies:",
- " apm:",
- )
- for _, dep := range apmDeps.Packages {
- lines = append(lines, fmt.Sprintf(" - %s", dep))
- }
- lines = append(lines,
- " APM_YAML",
- " apm install",
- )
-
- // -----------------------------------------------------------------------
- // Step 2: Pack the installed workspace with the JavaScript implementation
- // -----------------------------------------------------------------------
- githubScriptRef := GetActionPin("actions/github-script")
- lines = append(lines,
- " - name: Pack APM bundle",
- " id: apm_pack",
- " uses: "+githubScriptRef,
- " env:",
- " APM_WORKSPACE: /tmp/gh-aw/apm-workspace",
- " APM_BUNDLE_OUTPUT: /tmp/gh-aw/apm-bundle-output",
- " APM_TARGET: "+target,
" with:",
" script: |",
" const { setupGlobals } = require('"+SetupActionDestination+"/setup_globals.cjs');",
" setupGlobals(core, github, context, exec, io);",
- " const { main } = require('"+SetupActionDestination+"/apm_pack.cjs');",
- " await main();",
+ " const { main: apmInstall } = require('"+SetupActionDestination+"/apm_install.cjs');",
+ " await apmInstall();",
+ " const { main: apmPack } = require('"+SetupActionDestination+"/apm_pack.cjs');",
+ " await apmPack();",
)
return GitHubActionStep(lines)
diff --git a/pkg/workflow/apm_dependencies_test.go b/pkg/workflow/apm_dependencies_test.go
index 3e10fa2dd94..28101f012be 100644
--- a/pkg/workflow/apm_dependencies_test.go
+++ b/pkg/workflow/apm_dependencies_test.go
@@ -350,17 +350,15 @@ func TestGenerateAPMPackStep(t *testing.T) {
apmDeps: &APMDependenciesInfo{Packages: []string{"microsoft/apm-sample-package"}},
target: "copilot",
expectedContains: []string{
- "Install APM CLI and packages",
- "pip install",
- "apm install",
- "apm.yml",
- "- microsoft/apm-sample-package",
- "Pack APM bundle",
+ "Install and pack APM bundle",
"id: apm_pack",
"actions/github-script",
+ "apm_install.cjs",
"apm_pack.cjs",
+ `APM_PACKAGES: '["microsoft/apm-sample-package"]'`,
"APM_TARGET: copilot",
"APM_WORKSPACE: /tmp/gh-aw/apm-workspace",
+ "GITHUB_APM_PAT:",
},
},
{
@@ -368,13 +366,13 @@ func TestGenerateAPMPackStep(t *testing.T) {
apmDeps: &APMDependenciesInfo{Packages: []string{"microsoft/apm-sample-package", "github/skills/review"}},
target: "claude",
expectedContains: []string{
- "Install APM CLI and packages",
- "- microsoft/apm-sample-package",
- "- github/skills/review",
- "Pack APM bundle",
+ "Install and pack APM bundle",
"id: apm_pack",
- "APM_TARGET: claude",
+ "apm_install.cjs",
"apm_pack.cjs",
+ `"microsoft/apm-sample-package"`,
+ `"github/skills/review"`,
+ "APM_TARGET: claude",
},
},
{
@@ -383,16 +381,17 @@ func TestGenerateAPMPackStep(t *testing.T) {
target: "all",
expectedContains: []string{
"APM_TARGET: all",
+ "apm_install.cjs",
"apm_pack.cjs",
},
},
{
- name: "microsoft/apm-action is not referenced in new implementation",
+ name: "No pip install or apm-cli in new JS-only implementation",
apmDeps: &APMDependenciesInfo{Packages: []string{"microsoft/apm-sample-package"}, Version: "v1.0.0"},
target: "copilot",
expectedContains: []string{
- "pip install",
- "apm install",
+ "apm_install.cjs",
+ "apm_pack.cjs",
},
},
}
@@ -410,8 +409,10 @@ func TestGenerateAPMPackStep(t *testing.T) {
require.NotEmpty(t, step, "Step should not be empty")
combined := combineStepLines(step)
- // New implementation does not use microsoft/apm-action
- assert.NotContains(t, combined, "microsoft/apm-action", "New implementation should not reference microsoft/apm-action")
+ // New JS-only implementation must not reference Python tooling or microsoft/apm-action
+ assert.NotContains(t, combined, "microsoft/apm-action", "Should not reference microsoft/apm-action")
+ assert.NotContains(t, combined, "pip install", "Should not use pip install")
+ assert.NotContains(t, combined, "apm install", "Should not call apm CLI install")
for _, expected := range tt.expectedContains {
assert.Contains(t, combined, expected, "Step should contain: %s", expected)
From fb2602395d051584e08ae44c73dd7ac8bb2451e6 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 29 Mar 2026 14:38:25 +0000
Subject: [PATCH 07/12] fix: add checkout+setup steps to APM job so
apm_install.cjs is available at runtime
The APM job was failing with 'Cannot find module setup_globals.cjs' because it
was missing the actions/checkout + ./actions/setup steps that copy the .cjs files
to $RUNNER_TEMP/gh-aw/actions/.
buildAPMJob now follows the same pattern as buildMainJob and
buildConsolidatedSafeOutputsJob: add generateCheckoutActionsFolder +
generateSetupStep before the pack step. In dev/script mode this adds
contents: read permission for the checkout step.
Fixes: https://github.com/github/gh-aw/actions/runs/23711158124/job/69071054927
Agent-Logs-Url: https://github.com/github/gh-aw/sessions/992b4399-b580-45b4-b8b1-edb354ab6620
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
---
.github/workflows/smoke-claude.lock.yml | 14 +++++++++++-
pkg/workflow/compiler_apm_job.go | 29 +++++++++++++++++++------
2 files changed, 35 insertions(+), 8 deletions(-)
diff --git a/.github/workflows/smoke-claude.lock.yml b/.github/workflows/smoke-claude.lock.yml
index 38f4039ecf0..a3656ab39f2 100644
--- a/.github/workflows/smoke-claude.lock.yml
+++ b/.github/workflows/smoke-claude.lock.yml
@@ -2298,10 +2298,22 @@ jobs:
apm:
needs: activation
runs-on: ubuntu-slim
- permissions: {}
+ permissions:
+ contents: read
env:
GH_AW_INFO_APM_VERSION: v0.8.6
steps:
+ - name: Checkout actions folder
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ repository: github/gh-aw
+ sparse-checkout: |
+ actions
+ persist-credentials: false
+ - name: Setup Scripts
+ uses: ./actions/setup
+ with:
+ destination: ${{ runner.temp }}/gh-aw/actions
- name: Install and pack APM bundle
id: apm_pack
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
diff --git a/pkg/workflow/compiler_apm_job.go b/pkg/workflow/compiler_apm_job.go
index 50f1c192224..5408e9466a4 100644
--- a/pkg/workflow/compiler_apm_job.go
+++ b/pkg/workflow/compiler_apm_job.go
@@ -13,8 +13,9 @@ var compilerAPMJobLog = logger.New("workflow:compiler_apm_job")
// 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.
+// The APM job requires the gh-aw setup action to copy .cjs scripts to $RUNNER_TEMP/gh-aw/actions/
+// so that apm_install.cjs and apm_pack.cjs can be required by the github-script step.
+// In dev/script mode this means adding a checkout step (contents: read) before setup.
func (c *Compiler) buildAPMJob(data *WorkflowData) (*Job, error) {
compilerAPMJobLog.Printf("Building APM job: %d packages", len(data.APMDependencies.Packages))
@@ -25,6 +26,15 @@ func (c *Compiler) buildAPMJob(data *WorkflowData) (*Job, error) {
var steps []string
+ // Add setup action to copy JavaScript files (apm_install.cjs, apm_pack.cjs, etc.)
+ // to $RUNNER_TEMP/gh-aw/actions/ before the github-script step runs.
+ setupActionRef := c.resolveActionReference("./actions/setup", data)
+ if setupActionRef != "" || c.actionMode.IsScript() {
+ // For dev/script mode (local action path), checkout the actions folder first
+ steps = append(steps, c.generateCheckoutActionsFolder(data)...)
+ steps = append(steps, c.generateSetupStep(setupActionRef, SetupActionDestination, false)...)
+ }
+
// 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.
@@ -46,7 +56,7 @@ func (c *Compiler) buildAPMJob(data *WorkflowData) (*Job, error) {
}
// 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
+ // The path comes from the apm_pack step output `bundle-path`, which apm_pack.cjs
// 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")
@@ -75,10 +85,15 @@ func (c *Compiler) buildAPMJob(data *WorkflowData) (*Job, error) {
"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()
+ // Permissions: start with empty (minimal privilege) and add contents: read when a
+ // checkout step is needed to copy the gh-aw actions folder (dev/script mode).
+ var permissions string
+ needsContentsRead := (c.actionMode.IsDev() || c.actionMode.IsScript()) && len(c.generateCheckoutActionsFolder(data)) > 0
+ if needsContentsRead {
+ permissions = NewPermissionsContentsRead().RenderToYAML()
+ } else {
+ permissions = NewPermissionsEmpty().RenderToYAML()
+ }
job := &Job{
Name: string(constants.APMJobName),
From 89ed17cfbb2fd3295714d64578dc89a64b21f4aa Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 29 Mar 2026 15:00:50 +0000
Subject: [PATCH 08/12] fix: pass enableCustomTokens=true in APM job setup step
to install @actions/github
apm_install.cjs requires @actions/github to create an authenticated Octokit
client. The setup action only runs `npm install @actions/github` when the
safe-output-custom-tokens flag is true. Pass true for the APM job so the
package is available when apm_install.cjs is required by github-script.
Fixes: https://github.com/github/gh-aw/actions/runs/23711693514/job/69072387005
Agent-Logs-Url: https://github.com/github/gh-aw/sessions/3013d74c-f074-4a79-ac6b-d28bbfccf86c
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
---
.github/workflows/smoke-claude.lock.yml | 1 +
pkg/workflow/compiler_apm_job.go | 4 +++-
2 files changed, 4 insertions(+), 1 deletion(-)
diff --git a/.github/workflows/smoke-claude.lock.yml b/.github/workflows/smoke-claude.lock.yml
index a3656ab39f2..e38f129757a 100644
--- a/.github/workflows/smoke-claude.lock.yml
+++ b/.github/workflows/smoke-claude.lock.yml
@@ -2314,6 +2314,7 @@ jobs:
uses: ./actions/setup
with:
destination: ${{ runner.temp }}/gh-aw/actions
+ safe-output-custom-tokens: 'true'
- name: Install and pack APM bundle
id: apm_pack
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
diff --git a/pkg/workflow/compiler_apm_job.go b/pkg/workflow/compiler_apm_job.go
index 5408e9466a4..c9ffc98aae3 100644
--- a/pkg/workflow/compiler_apm_job.go
+++ b/pkg/workflow/compiler_apm_job.go
@@ -32,7 +32,9 @@ func (c *Compiler) buildAPMJob(data *WorkflowData) (*Job, error) {
if setupActionRef != "" || c.actionMode.IsScript() {
// For dev/script mode (local action path), checkout the actions folder first
steps = append(steps, c.generateCheckoutActionsFolder(data)...)
- steps = append(steps, c.generateSetupStep(setupActionRef, SetupActionDestination, false)...)
+ // APM install (apm_install.cjs) requires @actions/github to create an Octokit client.
+ // Pass enableCustomTokens=true so the setup action runs `npm install @actions/github`.
+ steps = append(steps, c.generateSetupStep(setupActionRef, SetupActionDestination, true)...)
}
// Mint a GitHub App token before the pack step if a github-app is configured for APM.
From 4dfd6ca21bfabdfa26ec8abfdf1aaad8810494f7 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 29 Mar 2026 15:11:19 +0000
Subject: [PATCH 09/12] test: add apm-parity-test job comparing JS and Python
apm-cli output for microsoft/apm-sample-package
Agent-Logs-Url: https://github.com/github/gh-aw/sessions/f0a89f89-af57-4237-8ec6-d8a322d680c3
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
---
.github/workflows/ci.yml | 180 +++++++++++++++++++++++++++++++++++++++
1 file changed, 180 insertions(+)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 49811d87583..4d4af23d8b5 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -1106,6 +1106,186 @@ jobs:
fi
+ apm-parity-test:
+ name: APM JS/Python Parity Test (microsoft/apm-sample-package)
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ needs: validate-yaml
+ permissions:
+ contents: read
+ concurrency:
+ group: ci-${{ github.ref }}-apm-parity-test
+ cancel-in-progress: true
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+
+ - name: Set up Python
+ uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
+ with:
+ python-version: "3.x"
+
+ - name: Set up Node.js
+ 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: Install apm-cli
+ run: pip install apm-cli
+
+ - name: Check if GitHub PAT is available
+ id: check-pat
+ env:
+ GITHUB_APM_PAT: ${{ secrets.GH_AW_PLUGINS_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
+ run: |
+ if [ -z "$GITHUB_APM_PAT" ]; then
+ echo "skip=true" >> "$GITHUB_OUTPUT"
+ echo "⚠️ No GITHUB_APM_PAT available — skipping parity test" >> $GITHUB_STEP_SUMMARY
+ else
+ echo "skip=false" >> "$GITHUB_OUTPUT"
+ echo "✅ GITHUB_APM_PAT available" >> $GITHUB_STEP_SUMMARY
+ fi
+
+ # ── Python path ──────────────────────────────────────────────────────────
+
+ - name: Install package with Python apm-cli
+ if: steps.check-pat.outputs.skip == 'false'
+ env:
+ GITHUB_APM_PAT: ${{ secrets.GH_AW_PLUGINS_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
+ run: |
+ set -e
+ mkdir -p /tmp/apm-workspace-python
+ cd /tmp/apm-workspace-python
+ cat > apm.yml << 'APMEOF'
+ name: apm-parity-test
+ version: 0.0.0
+ dependencies:
+ apm:
+ - microsoft/apm-sample-package
+ APMEOF
+ apm install
+ echo "=== Python workspace after apm install ==="
+ find /tmp/apm-workspace-python -type f | sort
+
+ - name: Pack with Python apm-cli
+ if: steps.check-pat.outputs.skip == 'false'
+ run: |
+ set -e
+ mkdir -p /tmp/apm-bundle-python
+ cd /tmp/apm-workspace-python
+ apm pack --archive --output /tmp/apm-bundle-python
+ echo "=== Python bundle ==="
+ ls -lh /tmp/apm-bundle-python/*.tar.gz
+
+ # ── JavaScript path ──────────────────────────────────────────────────────
+
+ - name: Install package with JavaScript (apm_install.cjs)
+ if: steps.check-pat.outputs.skip == 'false'
+ env:
+ GITHUB_APM_PAT: ${{ secrets.GH_AW_PLUGINS_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
+ APM_PACKAGES: '["microsoft/apm-sample-package"]'
+ APM_WORKSPACE: /tmp/apm-workspace-js
+ run: |
+ set -e
+ mkdir -p /tmp/apm-workspace-js
+ node actions/setup/js/run_apm_install.cjs
+ echo "=== JS workspace after apm_install.cjs ==="
+ find /tmp/apm-workspace-js -type f | sort
+
+ - name: Pack with JavaScript (apm_pack.cjs)
+ if: steps.check-pat.outputs.skip == 'false'
+ env:
+ APM_WORKSPACE: /tmp/apm-workspace-js
+ APM_BUNDLE_OUTPUT: /tmp/apm-bundle-js
+ run: |
+ set -e
+ mkdir -p /tmp/apm-bundle-js
+ node actions/setup/js/run_apm_pack.cjs
+ echo "=== JS bundle ==="
+ ls -lh /tmp/apm-bundle-js/*.tar.gz
+
+ # ── Comparison ───────────────────────────────────────────────────────────
+
+ - name: Unpack both bundles with JavaScript (apm_unpack.cjs)
+ if: steps.check-pat.outputs.skip == 'false'
+ run: |
+ set -e
+ mkdir -p /tmp/apm-out-python /tmp/apm-out-js
+ APM_BUNDLE_DIR=/tmp/apm-bundle-python OUTPUT_DIR=/tmp/apm-out-python \
+ node actions/setup/js/run_apm_unpack.cjs
+ APM_BUNDLE_DIR=/tmp/apm-bundle-js OUTPUT_DIR=/tmp/apm-out-js \
+ node actions/setup/js/run_apm_unpack.cjs
+ echo "=== Python bundle unpacked ==="
+ find /tmp/apm-out-python -type f | sort
+ echo "=== JS bundle unpacked ==="
+ find /tmp/apm-out-js -type f | sort
+
+ - name: Compare Python and JavaScript outputs
+ if: steps.check-pat.outputs.skip == 'false'
+ run: |
+ set -e
+ echo "## APM JS/Python Parity Test" >> $GITHUB_STEP_SUMMARY
+ echo "" >> $GITHUB_STEP_SUMMARY
+ echo "Comparing pack output for \`microsoft/apm-sample-package\`" >> $GITHUB_STEP_SUMMARY
+ echo "" >> $GITHUB_STEP_SUMMARY
+
+ PASS=true
+
+ # Compare file lists, excluding apm.lock.yaml which has minor metadata
+ # differences between implementations (resolved_by, timestamps, etc.)
+ python_files=$(find /tmp/apm-out-python -type f | sort | sed "s|/tmp/apm-out-python/||" | grep -v "^apm\.lock\.yaml$")
+ js_files=$(find /tmp/apm-out-js -type f | sort | sed "s|/tmp/apm-out-js/||" | grep -v "^apm\.lock\.yaml$")
+
+ if [ "$python_files" = "$js_files" ]; then
+ echo "✅ File lists match" >> $GITHUB_STEP_SUMMARY
+ else
+ echo "❌ File lists differ" >> $GITHUB_STEP_SUMMARY
+ echo '```diff' >> $GITHUB_STEP_SUMMARY
+ diff <(echo "$python_files") <(echo "$js_files") >> $GITHUB_STEP_SUMMARY || true
+ echo '```' >> $GITHUB_STEP_SUMMARY
+ PASS=false
+ fi
+
+ # Compare content of each matching file
+ CONTENT_DIFFS=0
+ for rel_path in $python_files; do
+ py_file="/tmp/apm-out-python/$rel_path"
+ js_file="/tmp/apm-out-js/$rel_path"
+ if [ ! -f "$js_file" ]; then
+ continue
+ fi
+ if ! diff -q "$py_file" "$js_file" > /dev/null 2>&1; then
+ echo "❌ Content differs: $rel_path" >> $GITHUB_STEP_SUMMARY
+ echo '```diff' >> $GITHUB_STEP_SUMMARY
+ diff "$py_file" "$js_file" >> $GITHUB_STEP_SUMMARY || true
+ echo '```' >> $GITHUB_STEP_SUMMARY
+ CONTENT_DIFFS=$((CONTENT_DIFFS + 1))
+ PASS=false
+ fi
+ done
+ if [ "$CONTENT_DIFFS" -eq 0 ] && [ "$PASS" = "true" ]; then
+ echo "✅ All file contents match" >> $GITHUB_STEP_SUMMARY
+ fi
+
+ echo "" >> $GITHUB_STEP_SUMMARY
+ echo "### Files in Python output:" >> $GITHUB_STEP_SUMMARY
+ echo '```' >> $GITHUB_STEP_SUMMARY
+ echo "$python_files" >> $GITHUB_STEP_SUMMARY
+ echo '```' >> $GITHUB_STEP_SUMMARY
+
+ if [ "$PASS" = "true" ]; then
+ echo "### ✅ JS and Python outputs are identical" >> $GITHUB_STEP_SUMMARY
+ else
+ echo "### ❌ JS and Python outputs differ" >> $GITHUB_STEP_SUMMARY
+ exit 1
+ fi
+
+
bench:
# Only run benchmarks on main branch for performance tracking
if: github.ref == 'refs/heads/main'
From 5c226a5f7e705d181114690c57325f5ce9abe37a Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 29 Mar 2026 15:25:14 +0000
Subject: [PATCH 10/12] feat: add make test-apm target and fix TypeScript
errors in APM JS files
Agent-Logs-Url: https://github.com/github/gh-aw/sessions/de476d92-cd01-469c-9b3c-8414ef6dfd75
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
---
Makefile | 6 ++++++
actions/setup/js/apm_install.cjs | 4 +++-
actions/setup/js/run_apm_install.cjs | 6 +++---
actions/setup/js/run_apm_pack.cjs | 6 +++---
actions/setup/js/tsconfig.json | 2 +-
5 files changed, 16 insertions(+), 8 deletions(-)
diff --git a/Makefile b/Makefile
index 61bd74195df..c9771a63eff 100644
--- a/Makefile
+++ b/Makefile
@@ -216,6 +216,11 @@ security-govulncheck:
test-js: build-js
cd actions/setup/js && npm run test:js -- --no-file-parallelism
+# Test APM JavaScript support (apm_install, apm_pack, apm_unpack)
+.PHONY: test-apm
+test-apm: build-js
+ cd actions/setup/js && npx vitest run --no-file-parallelism --reporter=verbose apm_install.test.cjs apm_pack.test.cjs apm_unpack.test.cjs
+
# Install JavaScript dependencies
.PHONY: deps-js
deps-js: check-node-version
@@ -760,6 +765,7 @@ help:
@echo " test-unit - Run Go unit tests only (faster)"
@echo " test-security - Run security regression tests"
@echo " test-js - Run JavaScript tests"
+ @echo " test-apm - Run APM JavaScript tests (apm_install, apm_pack, apm_unpack)"
@echo " test-all - Run all tests (Go, JavaScript, and wasm golden)"
@echo " test-wasm-golden - Run wasm golden tests (Go string API path)"
@echo " test-wasm - Build wasm and run Node.js golden comparison test"
diff --git a/actions/setup/js/apm_install.cjs b/actions/setup/js/apm_install.cjs
index 29bdb3666f5..dee8eaa298e 100644
--- a/actions/setup/js/apm_install.cjs
+++ b/actions/setup/js/apm_install.cjs
@@ -231,9 +231,11 @@ async function resolveCommit(octokit, owner, repo, ref) {
ref: effectiveRef,
});
+ // effectiveRef is always non-null here (set to default_branch if null was passed)
+ const resolvedRef = effectiveRef ?? "";
return {
commitSha: commitData.sha,
- resolvedRef: effectiveRef,
+ resolvedRef,
treeSha: commitData.commit.tree.sha,
};
}
diff --git a/actions/setup/js/run_apm_install.cjs b/actions/setup/js/run_apm_install.cjs
index 15289d41aef..e29b9dbe90d 100644
--- a/actions/setup/js/run_apm_install.cjs
+++ b/actions/setup/js/run_apm_install.cjs
@@ -34,10 +34,10 @@ const core = {
// Wire shims into globals so apm_install.cjs can use them.
setupGlobals(
- core, // logging
+ /** @type {any} */ core, // logging
{}, // @actions/github — not used directly (apm_install creates its own Octokit)
- {}, // GitHub Actions event context — not used
- {}, // @actions/exec — not used
+ /** @type {any} */ {}, // GitHub Actions event context — not used
+ /** @type {any} */ {}, // @actions/exec — not used
{} // @actions/io — not used
);
diff --git a/actions/setup/js/run_apm_pack.cjs b/actions/setup/js/run_apm_pack.cjs
index d264862b60f..0a3069137c4 100644
--- a/actions/setup/js/run_apm_pack.cjs
+++ b/actions/setup/js/run_apm_pack.cjs
@@ -52,10 +52,10 @@ const exec = {
// Wire shims into globals so apm_pack.cjs (and the imported apm_unpack.cjs) can use them.
setupGlobals(
- core, // logging, outputs, inputs
+ /** @type {any} */ core, // logging, outputs, inputs
{}, // @actions/github – not used by apm_pack
- {}, // GitHub Actions event context – not used by apm_pack
- exec, // runs `tar -czf`
+ /** @type {any} */ {}, // GitHub Actions event context – not used by apm_pack
+ /** @type {any} */ exec, // runs `tar -czf`
{} // @actions/io – not used by apm_pack
);
diff --git a/actions/setup/js/tsconfig.json b/actions/setup/js/tsconfig.json
index 0f9de57f780..608be435914 100644
--- a/actions/setup/js/tsconfig.json
+++ b/actions/setup/js/tsconfig.json
@@ -32,5 +32,5 @@
"typeRoots": ["./node_modules/@types", "./types"]
},
"include": ["*.cjs", "types/*.d.ts"],
- "exclude": ["../../../node_modules", "../../../dist", "*.test.cjs", "run_apm_unpack.cjs"]
+ "exclude": ["../../../node_modules", "../../../dist", "*.test.cjs", "run_apm_unpack.cjs", "run_apm_install.cjs", "run_apm_pack.cjs"]
}
From f89a75422a874b57d885b9e900cdc5e39932e86a Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 30 Mar 2026 00:54:40 +0000
Subject: [PATCH 11/12] chore: recompile all workflows after merging
origin/main
Agent-Logs-Url: https://github.com/github/gh-aw/sessions/2a9921e0-82c7-481d-a818-1ffad473c721
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
---
.github/workflows/apm-js-mirror-monitor.lock.yml | 16 +++++++++++++---
.github/workflows/mcp-inspector.lock.yml | 2 +-
.github/workflows/smoke-claude.lock.yml | 2 +-
.github/workflows/smoke-codex.lock.yml | 2 +-
.github/workflows/smoke-copilot-arm.lock.yml | 2 +-
.github/workflows/smoke-copilot.lock.yml | 2 +-
6 files changed, 18 insertions(+), 8 deletions(-)
diff --git a/.github/workflows/apm-js-mirror-monitor.lock.yml b/.github/workflows/apm-js-mirror-monitor.lock.yml
index d7a828046ba..bdf7c5413d8 100644
--- a/.github/workflows/apm-js-mirror-monitor.lock.yml
+++ b/.github/workflows/apm-js-mirror-monitor.lock.yml
@@ -317,10 +317,15 @@ jobs:
- name: Restore cache-memory file share data
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
- key: memory-${{ env.GH_AW_WORKFLOW_ID_SANITIZED }}-${{ github.run_id }}
+ key: memory-none-nopolicy-${{ env.GH_AW_WORKFLOW_ID_SANITIZED }}-${{ github.run_id }}
path: /tmp/gh-aw/cache-memory
restore-keys: |
- memory-${{ env.GH_AW_WORKFLOW_ID_SANITIZED }}-
+ memory-none-nopolicy-${{ env.GH_AW_WORKFLOW_ID_SANITIZED }}-
+ - name: Set up cache-memory git repository
+ env:
+ GH_AW_CACHE_DIR: /tmp/gh-aw/cache-memory
+ GH_AW_MIN_INTEGRITY: none
+ run: bash ${RUNNER_TEMP}/gh-aw/actions/setup_cache_memory_git.sh
- name: Configure Git credentials
env:
REPO_NAME: ${{ github.repository }}
@@ -839,6 +844,11 @@ jobs:
if [ ! -f /tmp/gh-aw/agent_output.json ]; then
echo '{"items":[]}' > /tmp/gh-aw/agent_output.json
fi
+ - name: Commit cache-memory changes
+ if: always()
+ env:
+ GH_AW_CACHE_DIR: /tmp/gh-aw/cache-memory
+ run: bash ${RUNNER_TEMP}/gh-aw/actions/commit_cache_memory_git.sh
- name: Upload cache-memory data as artifact
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
if: always()
@@ -1375,6 +1385,6 @@ jobs:
if: steps.check_cache_default.outputs.has_content == 'true'
uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
- key: memory-${{ env.GH_AW_WORKFLOW_ID_SANITIZED }}-${{ github.run_id }}
+ key: memory-none-nopolicy-${{ env.GH_AW_WORKFLOW_ID_SANITIZED }}-${{ github.run_id }}
path: /tmp/gh-aw/cache-memory
diff --git a/.github/workflows/mcp-inspector.lock.yml b/.github/workflows/mcp-inspector.lock.yml
index 8b2ab211bc3..4c0949008c2 100644
--- a/.github/workflows/mcp-inspector.lock.yml
+++ b/.github/workflows/mcp-inspector.lock.yml
@@ -164,7 +164,7 @@ jobs:
cat "${RUNNER_TEMP}/gh-aw/prompts/safe_outputs_prompt.md"
cat << 'GH_AW_PROMPT_595ce40f90294a31_EOF'
- Tools: create_discussion, missing_tool, missing_data, noop
+ Tools: create_discussion, missing_tool, missing_data, noop, notion_add_comment, post_to_slack_channel
The following GitHub context information is available for this workflow:
diff --git a/.github/workflows/smoke-claude.lock.yml b/.github/workflows/smoke-claude.lock.yml
index de59bd4795a..038841568cd 100644
--- a/.github/workflows/smoke-claude.lock.yml
+++ b/.github/workflows/smoke-claude.lock.yml
@@ -212,7 +212,7 @@ jobs:
cat "${RUNNER_TEMP}/gh-aw/prompts/safe_outputs_prompt.md"
cat << 'GH_AW_PROMPT_c07dfaff6885ae7d_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
+ 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
cat "${RUNNER_TEMP}/gh-aw/prompts/safe_outputs_push_to_pr_branch.md"
cat << 'GH_AW_PROMPT_c07dfaff6885ae7d_EOF'
diff --git a/.github/workflows/smoke-codex.lock.yml b/.github/workflows/smoke-codex.lock.yml
index 24cfc051efa..302eb683f9a 100644
--- a/.github/workflows/smoke-codex.lock.yml
+++ b/.github/workflows/smoke-codex.lock.yml
@@ -206,7 +206,7 @@ jobs:
cat "${RUNNER_TEMP}/gh-aw/prompts/safe_outputs_prompt.md"
cat << 'GH_AW_PROMPT_20c992e85e9b3ade_EOF'
- Tools: add_comment(max:2), create_issue, add_labels, remove_labels, unassign_from_user, hide_comment(max:5), missing_tool, missing_data, noop
+ Tools: add_comment(max:2), create_issue, add_labels, remove_labels, unassign_from_user, hide_comment(max:5), missing_tool, missing_data, noop, add_smoked_label
The following GitHub context information is available for this workflow:
diff --git a/.github/workflows/smoke-copilot-arm.lock.yml b/.github/workflows/smoke-copilot-arm.lock.yml
index b8efd704e47..f0d5c80a195 100644
--- a/.github/workflows/smoke-copilot-arm.lock.yml
+++ b/.github/workflows/smoke-copilot-arm.lock.yml
@@ -203,7 +203,7 @@ jobs:
cat "${RUNNER_TEMP}/gh-aw/prompts/safe_outputs_prompt.md"
cat << 'GH_AW_PROMPT_1fdc0a0cb4a09695_EOF'
- Tools: add_comment(max:2), create_issue, create_discussion, create_pull_request_review_comment(max:5), submit_pull_request_review, add_labels, remove_labels, dispatch_workflow, missing_tool, missing_data, noop
+ Tools: add_comment(max:2), create_issue, create_discussion, create_pull_request_review_comment(max:5), submit_pull_request_review, add_labels, remove_labels, dispatch_workflow, missing_tool, missing_data, noop, send_slack_message
The following GitHub context information is available for this workflow:
diff --git a/.github/workflows/smoke-copilot.lock.yml b/.github/workflows/smoke-copilot.lock.yml
index c9dd1595d80..bc49dd5ca57 100644
--- a/.github/workflows/smoke-copilot.lock.yml
+++ b/.github/workflows/smoke-copilot.lock.yml
@@ -209,7 +209,7 @@ jobs:
cat "${RUNNER_TEMP}/gh-aw/prompts/safe_outputs_prompt.md"
cat << 'GH_AW_PROMPT_7b74fbc49451ebfd_EOF'
- Tools: add_comment(max:2), create_issue, create_discussion, create_pull_request_review_comment(max:5), submit_pull_request_review, reply_to_pull_request_review_comment(max:5), add_labels, remove_labels, set_issue_type, dispatch_workflow, missing_tool, missing_data, noop
+ Tools: add_comment(max:2), create_issue, create_discussion, create_pull_request_review_comment(max:5), submit_pull_request_review, reply_to_pull_request_review_comment(max:5), add_labels, remove_labels, set_issue_type, dispatch_workflow, missing_tool, missing_data, noop, send_slack_message
The following GitHub context information is available for this workflow:
From bdca4d0c0f232a1c87ab02f21a61bfa9172aa650 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 30 Mar 2026 02:45:39 +0000
Subject: [PATCH 12/12] fix: fall back to @octokit/core when @actions/github v9
(ESM-only) fails in CJS mode
Agent-Logs-Url: https://github.com/github/gh-aw/sessions/0c6814ff-57a1-4c17-b499-7ca0c94ae80c
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
---
actions/setup/js/apm_install.cjs | 23 ++++++++++++++++++-----
1 file changed, 18 insertions(+), 5 deletions(-)
diff --git a/actions/setup/js/apm_install.cjs b/actions/setup/js/apm_install.cjs
index dee8eaa298e..e79472cf400 100644
--- a/actions/setup/js/apm_install.cjs
+++ b/actions/setup/js/apm_install.cjs
@@ -197,11 +197,24 @@ function writeWorkspaceApmYml(workspaceDir) {
* @returns Octokit instance
*/
function createOctokit(token) {
- // @actions/github is bundled with actions/github-script and always available
- // in the github-script execution environment.
- // @ts-ignore – dynamic require at runtime
- const { getOctokit } = require("@actions/github");
- return getOctokit(token);
+ // @actions/github is bundled with actions/github-script and available in
+ // older CJS-compatible versions. When running standalone with @actions/github
+ // v9+ (ESM-only), fall back to @octokit/core + plugins.
+ try {
+ // @ts-ignore – dynamic require at runtime
+ const { getOctokit } = require("@actions/github");
+ return getOctokit(token);
+ } catch {
+ // @actions/github v9+ is ESM-only; use @octokit/core + rest-endpoint-methods
+ // @ts-ignore – dynamic require at runtime
+ const { Octokit } = require("@octokit/core");
+ // @ts-ignore – dynamic require at runtime
+ const { restEndpointMethods } = require("@octokit/plugin-rest-endpoint-methods");
+ // @ts-ignore – dynamic require at runtime
+ const { paginateRest } = require("@octokit/plugin-paginate-rest");
+ const MyOctokit = Octokit.plugin(restEndpointMethods, paginateRest);
+ return new MyOctokit({ auth: token });
+ }
}
// ---------------------------------------------------------------------------