From 263f27879d26b855413c5e84ec007584d7fe6ffc Mon Sep 17 00:00:00 2001 From: Salman Muin Kayser Chishti Date: Tue, 26 May 2026 12:53:52 +0000 Subject: [PATCH 1/6] feat(setup): add toolcache resolver for Copilot CLI behind feature flag Adds an opt-in path that skips the runtime `npm install -g @github/copilot` when a compatible build is already present in the runner tool cache. Behavior changes: - New `setup-copilot-resolver` feature flag (default off). When enabled in a workflow's frontmatter, the compiler emits `INPUT_INSTALL_COPILOT: 'true'` on the setup step and adds `if: steps.setup.outputs.copilot-cached != 'true'` to the compiler-emitted install step. With the flag off, the compiler emits identical YAML to before (verified: golden fixtures unchanged). - New `actions/setup/js/install_copilot_cli.cjs` resolver runs from setup.sh when `INPUT_INSTALL_COPILOT=true`. On a hit, it appends the toolcache bin dir to $GITHUB_PATH and writes `copilot-cached=true`. On any miss or error (no toolcache entry, version out of range, network failure, malformed matrix, etc.) it writes `copilot-cached=false` and exits 0 so the existing bash installer runs as before. - Resolver has zero npm dependencies (uses only fs/path/https) so it cannot introduce a new install step itself. - Two new step outputs on the setup action: `copilot-cached` and `copilot-path`. Resolution logic: - Fetches compat matrix from gh-aw-actions main, falls back to bundled `actions/setup/compat.json` on any error (5s timeout). - Picks the first matrix row whose `max-gh-aw` covers the current compiler version, then selects the highest cached version in [min-agent, max-agent]. - Toolcache layout matches runner-images convention: $RUNNER_TOOL_CACHE/copilot-cli///{bin/copilot, ..}.complete Tests: 23 vitest cases for the resolver covering semver parsing/comparison, matrix row matching, range selection, arch detection, and toolcache scanning (hit, miss, missing marker, missing binary, non-semver dir, empty cache). All existing Go workflow tests pass unchanged. --- actions/setup/action.yml | 4 + actions/setup/compat.json | 13 + actions/setup/js/install_copilot_cli.cjs | 309 ++++++++++++++++++ actions/setup/js/install_copilot_cli.test.cjs | 213 ++++++++++++ actions/setup/setup.sh | 16 + pkg/constants/feature_constants.go | 14 + pkg/workflow/cache.go | 2 +- .../compiler_activation_job_builder.go | 2 +- pkg/workflow/compiler_experiments.go | 2 +- pkg/workflow/compiler_main_job.go | 2 +- pkg/workflow/compiler_pre_activation_job.go | 2 +- pkg/workflow/compiler_safe_outputs_job.go | 4 +- pkg/workflow/compiler_unlock_job.go | 2 +- pkg/workflow/compiler_yaml_step_generation.go | 20 +- pkg/workflow/copilot_engine_installation.go | 65 ++++ pkg/workflow/notify_comment.go | 2 +- pkg/workflow/publish_assets.go | 2 +- pkg/workflow/repo_memory.go | 2 +- pkg/workflow/setup_step_version_test.go | 10 +- pkg/workflow/threat_detection.go | 2 +- 20 files changed, 669 insertions(+), 19 deletions(-) create mode 100644 actions/setup/compat.json create mode 100644 actions/setup/js/install_copilot_cli.cjs create mode 100644 actions/setup/js/install_copilot_cli.test.cjs diff --git a/actions/setup/action.yml b/actions/setup/action.yml index d70cc20d41a..9ce7fe8c8ef 100644 --- a/actions/setup/action.yml +++ b/actions/setup/action.yml @@ -32,6 +32,10 @@ outputs: description: 'The OTLP span ID used for the gh-aw..setup span. Pass this to subsequent job setup steps via the parent-span-id input so setup spans are properly parented.' parent-span-id: description: 'The OTLP parent span ID used for the gh-aw..setup span. Pass this through downstream jobs so all setup spans share the same global parent span.' + copilot-cached: + description: 'Set to "true" when the Copilot CLI resolver found a cached, gh-aw-compatible version in the runner tool cache and added it to PATH. Set to "false" (or unset) when no cached version was usable. Compiler-emitted installer steps gate themselves on this output. Only populated when the workflow opts in via the setup-copilot-resolver feature flag.' + copilot-path: + description: 'Absolute path to the directory added to PATH when copilot-cached=true. Empty otherwise. Useful for diagnostics.' runs: using: 'node24' diff --git a/actions/setup/compat.json b/actions/setup/compat.json new file mode 100644 index 00000000000..9030458881c --- /dev/null +++ b/actions/setup/compat.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://json-schema.org/draft-07/schema#", + "_comment": "Bundled fallback compatibility matrix for the Copilot CLI resolver. Used when install_copilot_cli.cjs cannot fetch the upstream matrix from github/gh-aw-actions. Each entry in agent-compat-v1.copilot describes a range of cached agent versions known to work with a range of gh-aw compiler versions. Resolver picks the highest agent version in [min-agent, max-agent] whose row matches the current gh-aw version. See actions/setup/js/install_copilot_cli.cjs for selection logic.", + "agent-compat-v1": { + "copilot": [ + { + "max-gh-aw": "*", + "min-agent": "1.0.50", + "max-agent": "1.0.54" + } + ] + } +} diff --git a/actions/setup/js/install_copilot_cli.cjs b/actions/setup/js/install_copilot_cli.cjs new file mode 100644 index 00000000000..f1e6350c5fb --- /dev/null +++ b/actions/setup/js/install_copilot_cli.cjs @@ -0,0 +1,309 @@ +// install_copilot_cli.cjs — zero-dependency Copilot CLI resolver +// +// Runs from actions/setup/setup.sh when the compiler emits INPUT_INSTALL_COPILOT=true. +// Looks for a cached, gh-aw-compatible build of @github/copilot in the runner +// tool cache. On a hit, appends the bin directory to $GITHUB_PATH and writes +// `copilot-cached=true` / `copilot-path=` to $GITHUB_OUTPUT so the +// compiler-emitted installer step skips itself. On any miss or error, writes +// `copilot-cached=false` and exits 0 — the installer step then runs as before. +// +// Design constraints (see ADR-10093): +// - No third-party deps (cannot rely on @actions/tool-cache being present). +// - Never throws / never exits non-zero — fall back to the existing installer. +// - Resolve bundled compat.json via __dirname (script mode runs from a +// non-cwd location). +// - Fetch the live matrix from gh-aw-actions main (best-effort, 5s timeout) +// and fall back to the bundled snapshot on any error. +// +// Matrix entry format (see actions/setup/compat.json): +// { "max-gh-aw": "*"|, "min-agent": , "max-agent": } + +const fs = require("fs"); +const path = require("path"); +const https = require("https"); + +const COMPAT_URL = "https://raw.githubusercontent.com/github/gh-aw-actions/main/.github/aw/compat.json"; +const FETCH_TIMEOUT_MS = 5000; + +function log(msg) { + console.log(`[install_copilot_cli] ${msg}`); +} + +function logErr(msg) { + console.error(`[install_copilot_cli] ${msg}`); +} + +// Parse a SemVer string into a comparable tuple. Returns null on malformed +// input so callers can skip the entry rather than crash. +function parseSemver(v) { + if (typeof v !== "string") return null; + const m = v.match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/); + if (!m) return null; + return [Number(m[1]), Number(m[2]), Number(m[3]), m[4] || ""]; +} + +// Compare two parsed SemVers. Returns -1/0/1. Treats any pre-release as lower +// than its release counterpart (sufficient for our pinning use case). +function cmpSemver(a, b) { + for (let i = 0; i < 3; i++) { + if (a[i] !== b[i]) return a[i] < b[i] ? -1 : 1; + } + if (a[3] === b[3]) return 0; + if (!a[3]) return 1; + if (!b[3]) return -1; + return a[3] < b[3] ? -1 : 1; +} + +// Does the matrix row's `max-gh-aw` cover the current gh-aw compiler version? +// "*" always matches. Otherwise the compiler version must be <= max-gh-aw. +// Unparseable compiler versions (e.g., "dev") are treated as matching only "*". +function rowMatchesGhAw(row, ghAwSemver) { + const maxGhAw = row && row["max-gh-aw"]; + if (maxGhAw === "*") return true; + if (!ghAwSemver) return false; + const max = parseSemver(maxGhAw); + if (!max) return false; + return cmpSemver(ghAwSemver, max) <= 0; +} + +// Fetch the live matrix from gh-aw-actions. Resolves to parsed JSON or null +// on any error (network, timeout, non-200, malformed JSON). Never throws. +function fetchLiveMatrix() { + return new Promise(resolve => { + let settled = false; + const done = val => { + if (settled) return; + settled = true; + resolve(val); + }; + let req; + try { + req = https.get(COMPAT_URL, { timeout: FETCH_TIMEOUT_MS }, res => { + if (res.statusCode !== 200) { + res.resume(); + logErr(`live matrix fetch returned HTTP ${res.statusCode}`); + return done(null); + } + let body = ""; + res.setEncoding("utf8"); + res.on("data", chunk => { + body += chunk; + if (body.length > 1_000_000) { + req.destroy(); + done(null); + } + }); + res.on("end", () => { + try { + done(JSON.parse(body)); + } catch (e) { + logErr(`live matrix parse failed: ${e.message}`); + done(null); + } + }); + res.on("error", e => { + logErr(`live matrix stream error: ${e.message}`); + done(null); + }); + }); + req.on("timeout", () => { + req.destroy(); + logErr("live matrix fetch timed out"); + done(null); + }); + req.on("error", e => { + logErr(`live matrix request error: ${e.message}`); + done(null); + }); + } catch (e) { + logErr(`live matrix request setup failed: ${e.message}`); + done(null); + } + }); +} + +// Load the bundled fallback matrix from disk, resolved via __dirname so script +// mode (running from /tmp/gh-aw/actions-source/...) and dev/release mode both +// find it next to the setup action. +function loadBundledMatrix() { + try { + const p = path.join(__dirname, "..", "compat.json"); + return JSON.parse(fs.readFileSync(p, "utf8")); + } catch (e) { + logErr(`bundled matrix load failed: ${e.message}`); + return null; + } +} + +// Extract the copilot row list from a matrix document. Returns [] if the +// document is malformed (treated as "no compatible versions"). +function copilotRows(matrix) { + if (!matrix || typeof matrix !== "object") return []; + const v1 = matrix["agent-compat-v1"]; + if (!v1 || typeof v1 !== "object") return []; + const rows = v1["copilot"]; + return Array.isArray(rows) ? rows : []; +} + +// Pick the resolution range [min, max] from the first row whose max-gh-aw +// covers the current compiler version. Returns null when no row matches. +function pickRange(rows, ghAwSemver) { + for (const row of rows) { + if (!rowMatchesGhAw(row, ghAwSemver)) continue; + const min = parseSemver(row["min-agent"]); + const max = parseSemver(row["max-agent"]); + if (!min || !max) continue; + return { min, max }; + } + return null; +} + +// Map process.arch to the runner-images tool-cache arch directory name. +function detectArch() { + switch (process.arch) { + case "x64": + return "x64"; + case "arm64": + return "arm64"; + default: + return process.arch; + } +} + +// Find the highest cached Copilot CLI version in [min, max] under the runner +// tool cache. Returns { version, dir, binDir } on hit, null on miss. Only +// considers entries with a sibling .complete marker (matches @actions/tool-cache). +function findCachedCopilot(toolCacheRoot, arch, range) { + const baseDir = path.join(toolCacheRoot, "copilot-cli"); + let entries; + try { + entries = fs.readdirSync(baseDir); + } catch (e) { + if (e.code !== "ENOENT") logErr(`tool cache scan failed: ${e.message}`); + return null; + } + + let best = null; + for (const entry of entries) { + const v = parseSemver(entry); + if (!v) continue; + if (cmpSemver(v, range.min) < 0) continue; + if (cmpSemver(v, range.max) > 0) continue; + + const archDir = path.join(baseDir, entry, arch); + const marker = `${archDir}.complete`; + if (!fs.existsSync(marker)) continue; + + const binDir = path.join(archDir, "bin"); + const binFile = path.join(binDir, "copilot"); + if (!fs.existsSync(binFile)) continue; + + if (!best || cmpSemver(v, best.parsed) > 0) { + best = { parsed: v, version: entry, dir: archDir, binDir }; + } + } + + if (!best) return null; + return { version: best.version, dir: best.dir, binDir: best.binDir }; +} + +// Append a line to a GitHub Actions runner file (e.g., $GITHUB_PATH or +// $GITHUB_OUTPUT). No-ops when the path env var is unset so the resolver runs +// in local tests without polluting the workflow. +function appendRunnerFile(envVar, line) { + const p = process.env[envVar]; + if (!p) return; + try { + fs.appendFileSync(p, line.endsWith("\n") ? line : line + "\n", "utf8"); + } catch (e) { + logErr(`failed to append to ${envVar}: ${e.message}`); + } +} + +function writeOutput(name, value) { + appendRunnerFile("GITHUB_OUTPUT", `${name}=${value}`); +} + +function addToPath(dir) { + appendRunnerFile("GITHUB_PATH", dir); +} + +async function resolve() { + const ghAwVersionRaw = process.env.INPUT_GH_AW_VERSION || ""; + const ghAwSemver = parseSemver(ghAwVersionRaw); + if (ghAwVersionRaw && !ghAwSemver) { + log(`gh-aw version "${ghAwVersionRaw}" is not SemVer; only wildcard rows will match`); + } + + const toolCacheRoot = process.env.RUNNER_TOOL_CACHE || process.env.AGENT_TOOLSDIRECTORY; + if (!toolCacheRoot) { + log("RUNNER_TOOL_CACHE not set; treating as cache miss"); + writeOutput("copilot-cached", "false"); + writeOutput("copilot-path", ""); + return; + } + + // Try live matrix first, fall back to bundled. Either may be null. + let matrix = await fetchLiveMatrix(); + if (!matrix) { + log("falling back to bundled compat.json"); + matrix = loadBundledMatrix(); + } + const rows = copilotRows(matrix); + if (rows.length === 0) { + log("no copilot rows in compat matrix; treating as cache miss"); + writeOutput("copilot-cached", "false"); + writeOutput("copilot-path", ""); + return; + } + + const range = pickRange(rows, ghAwSemver); + if (!range) { + log(`no compat row matches gh-aw version "${ghAwVersionRaw}"; treating as cache miss`); + writeOutput("copilot-cached", "false"); + writeOutput("copilot-path", ""); + return; + } + + const arch = detectArch(); + const hit = findCachedCopilot(toolCacheRoot, arch, range); + if (!hit) { + log(`no cached copilot in [${range.min.slice(0, 3).join(".")}, ${range.max.slice(0, 3).join(".")}] for arch ${arch}`); + writeOutput("copilot-cached", "false"); + writeOutput("copilot-path", ""); + return; + } + + log(`cache hit: copilot ${hit.version} at ${hit.binDir}`); + addToPath(hit.binDir); + writeOutput("copilot-cached", "true"); + writeOutput("copilot-path", hit.binDir); +} + +// Top-level: never throw, always exit 0. Any unexpected error is logged and +// becomes a cache miss so the bash installer step takes over. Only runs when +// invoked as a script so the module can be require()d safely from tests. +if (require.main === module) { + resolve() + .catch(e => { + logErr(`unexpected error: ${e && e.stack ? e.stack : e}`); + try { + writeOutput("copilot-cached", "false"); + writeOutput("copilot-path", ""); + } catch { + // best effort + } + }) + .then(() => process.exit(0)); +} + +module.exports = { + parseSemver, + cmpSemver, + rowMatchesGhAw, + copilotRows, + pickRange, + detectArch, + findCachedCopilot, + resolve, +}; diff --git a/actions/setup/js/install_copilot_cli.test.cjs b/actions/setup/js/install_copilot_cli.test.cjs new file mode 100644 index 00000000000..7446e99b0fb --- /dev/null +++ b/actions/setup/js/install_copilot_cli.test.cjs @@ -0,0 +1,213 @@ +// @ts-check +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { createRequire } from "module"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; + +const req = createRequire(import.meta.url); +const { parseSemver, cmpSemver, rowMatchesGhAw, copilotRows, pickRange, detectArch, findCachedCopilot } = req("./install_copilot_cli.cjs"); + +describe("parseSemver", () => { + it("parses release versions", () => { + expect(parseSemver("1.2.3")).toEqual([1, 2, 3, ""]); + }); + + it("parses pre-release versions", () => { + expect(parseSemver("1.2.3-beta.1")).toEqual([1, 2, 3, "beta.1"]); + }); + + it("rejects non-string input", () => { + expect(parseSemver(null)).toBeNull(); + expect(parseSemver(undefined)).toBeNull(); + expect(parseSemver(123)).toBeNull(); + }); + + it("rejects malformed strings", () => { + expect(parseSemver("dev")).toBeNull(); + expect(parseSemver("1.2")).toBeNull(); + expect(parseSemver("v1.2.3")).toBeNull(); + expect(parseSemver("1.2.3.4")).toBeNull(); + }); +}); + +describe("cmpSemver", () => { + it("compares major/minor/patch", () => { + expect(cmpSemver([1, 0, 0, ""], [2, 0, 0, ""])).toBe(-1); + expect(cmpSemver([1, 5, 0, ""], [1, 2, 0, ""])).toBe(1); + expect(cmpSemver([1, 2, 3, ""], [1, 2, 3, ""])).toBe(0); + expect(cmpSemver([1, 2, 3, ""], [1, 2, 4, ""])).toBe(-1); + }); + + it("treats pre-release as lower than release", () => { + expect(cmpSemver([1, 0, 0, "beta"], [1, 0, 0, ""])).toBe(-1); + expect(cmpSemver([1, 0, 0, ""], [1, 0, 0, "beta"])).toBe(1); + }); +}); + +describe("rowMatchesGhAw", () => { + it("matches wildcard rows regardless of compiler version", () => { + expect(rowMatchesGhAw({ "max-gh-aw": "*" }, null)).toBe(true); + expect(rowMatchesGhAw({ "max-gh-aw": "*" }, [1, 2, 3, ""])).toBe(true); + }); + + it("matches when compiler version is <= max-gh-aw", () => { + expect(rowMatchesGhAw({ "max-gh-aw": "2.0.0" }, [1, 5, 0, ""])).toBe(true); + expect(rowMatchesGhAw({ "max-gh-aw": "2.0.0" }, [2, 0, 0, ""])).toBe(true); + }); + + it("rejects when compiler version exceeds max-gh-aw", () => { + expect(rowMatchesGhAw({ "max-gh-aw": "1.0.0" }, [2, 0, 0, ""])).toBe(false); + }); + + it("rejects non-wildcard rows when compiler version is unparseable", () => { + expect(rowMatchesGhAw({ "max-gh-aw": "2.0.0" }, null)).toBe(false); + }); +}); + +describe("copilotRows", () => { + it("returns rows from a well-formed matrix", () => { + const rows = copilotRows({ + "agent-compat-v1": { copilot: [{ "max-gh-aw": "*" }] }, + }); + expect(rows).toHaveLength(1); + }); + + it("returns [] for malformed inputs", () => { + expect(copilotRows(null)).toEqual([]); + expect(copilotRows({})).toEqual([]); + expect(copilotRows({ "agent-compat-v1": null })).toEqual([]); + expect(copilotRows({ "agent-compat-v1": { copilot: "not-an-array" } })).toEqual([]); + }); +}); + +describe("pickRange", () => { + it("returns the first matching row's range", () => { + const rows = [{ "max-gh-aw": "*", "min-agent": "1.0.50", "max-agent": "1.0.54" }]; + const r = pickRange(rows, null); + expect(r).not.toBeNull(); + expect(r.min.slice(0, 3)).toEqual([1, 0, 50]); + expect(r.max.slice(0, 3)).toEqual([1, 0, 54]); + }); + + it("skips rows whose max-gh-aw does not cover the compiler version", () => { + const rows = [ + { "max-gh-aw": "1.0.0", "min-agent": "1.0.40", "max-agent": "1.0.45" }, + { "max-gh-aw": "*", "min-agent": "1.0.50", "max-agent": "1.0.54" }, + ]; + const r = pickRange(rows, [2, 0, 0, ""]); + expect(r.min.slice(0, 3)).toEqual([1, 0, 50]); + }); + + it("skips rows with unparseable min/max-agent", () => { + const rows = [ + { "max-gh-aw": "*", "min-agent": "bad", "max-agent": "1.0.54" }, + { "max-gh-aw": "*", "min-agent": "1.0.50", "max-agent": "1.0.54" }, + ]; + const r = pickRange(rows, null); + expect(r.min.slice(0, 3)).toEqual([1, 0, 50]); + }); + + it("returns null when no row matches", () => { + expect(pickRange([], null)).toBeNull(); + }); +}); + +describe("detectArch", () => { + it("returns the current process arch", () => { + expect(typeof detectArch()).toBe("string"); + expect(detectArch().length).toBeGreaterThan(0); + }); +}); + +describe("findCachedCopilot", () => { + /** @type {string} */ + let toolCacheRoot; + const arch = "x64"; + + beforeEach(() => { + toolCacheRoot = fs.mkdtempSync(path.join(os.tmpdir(), "tc-test-")); + }); + + afterEach(() => { + fs.rmSync(toolCacheRoot, { recursive: true, force: true }); + }); + + /** + * Materialize the `$AGENT_TOOLSDIRECTORY/copilot-cli///bin/copilot` + * layout plus the `.complete` sibling marker that matches what + * `install-copilot-cli.sh` produces in runner-images. + */ + function seedToolcache(version, { complete = true, withBin = true } = {}) { + const versionDir = path.join(toolCacheRoot, "copilot-cli", version); + const archDir = path.join(versionDir, arch); + fs.mkdirSync(archDir, { recursive: true }); + if (withBin) { + const binDir = path.join(archDir, "bin"); + fs.mkdirSync(binDir, { recursive: true }); + fs.writeFileSync(path.join(binDir, "copilot"), "#!/bin/sh\n", { mode: 0o755 }); + } + if (complete) { + fs.writeFileSync(`${archDir}.complete`, ""); + } + } + + it("returns the highest version in range", () => { + seedToolcache("1.0.50"); + seedToolcache("1.0.52"); + seedToolcache("1.0.54"); + const r = findCachedCopilot(toolCacheRoot, arch, { + min: parseSemver("1.0.50"), + max: parseSemver("1.0.54"), + }); + expect(r).not.toBeNull(); + expect(r.version).toBe("1.0.54"); + expect(r.binDir).toBe(path.join(toolCacheRoot, "copilot-cli", "1.0.54", arch, "bin")); + }); + + it("ignores versions outside the range", () => { + seedToolcache("1.0.49"); + seedToolcache("1.0.55"); + const r = findCachedCopilot(toolCacheRoot, arch, { + min: parseSemver("1.0.50"), + max: parseSemver("1.0.54"), + }); + expect(r).toBeNull(); + }); + + it("ignores entries without a .complete marker", () => { + seedToolcache("1.0.52", { complete: false }); + const r = findCachedCopilot(toolCacheRoot, arch, { + min: parseSemver("1.0.50"), + max: parseSemver("1.0.54"), + }); + expect(r).toBeNull(); + }); + + it("ignores entries missing the copilot binary", () => { + seedToolcache("1.0.52", { withBin: false }); + const r = findCachedCopilot(toolCacheRoot, arch, { + min: parseSemver("1.0.50"), + max: parseSemver("1.0.54"), + }); + expect(r).toBeNull(); + }); + + it("ignores non-semver directory names", () => { + fs.mkdirSync(path.join(toolCacheRoot, "copilot-cli", "junk"), { recursive: true }); + seedToolcache("1.0.52"); + const r = findCachedCopilot(toolCacheRoot, arch, { + min: parseSemver("1.0.50"), + max: parseSemver("1.0.54"), + }); + expect(r.version).toBe("1.0.52"); + }); + + it("returns null when the copilot-cli directory does not exist", () => { + const r = findCachedCopilot(toolCacheRoot, arch, { + min: parseSemver("1.0.50"), + max: parseSemver("1.0.54"), + }); + expect(r).toBeNull(); + }); +}); diff --git a/actions/setup/setup.sh b/actions/setup/setup.sh index 98460a639bc..52223d103ed 100755 --- a/actions/setup/setup.sh +++ b/actions/setup/setup.sh @@ -434,6 +434,22 @@ else debug_log "Artifact client not enabled - skipping @actions/artifact installation" fi +# Resolve a cached, gh-aw-compatible Copilot CLI from the runner tool cache when +# requested by the compiler. The resolver writes the `copilot-cached` step output +# and, on a cache hit, appends its tool-cache bin directory to $GITHUB_PATH. +# This runs *before* the OTLP span so that the resolver's outcome is visible in +# the same step. The resolver never throws: any error is logged and treated as a +# cache miss, allowing the workflow's existing bash installer step to run. +if [ "${INPUT_INSTALL_COPILOT:-false}" = "true" ]; then + if command -v node &>/dev/null && [ -f "${SCRIPT_DIR}/js/install_copilot_cli.cjs" ]; then + debug_log "Running Copilot CLI resolver (gh-aw-version=${INPUT_GH_AW_VERSION:-unknown})..." + INPUT_GH_AW_VERSION="${INPUT_GH_AW_VERSION:-}" node "${SCRIPT_DIR}/js/install_copilot_cli.cjs" || true + debug_log "Copilot CLI resolver step complete" + else + debug_log "Copilot CLI resolver skipped: node or resolver script unavailable" + fi +fi + # Send OTLP job setup span when configured (non-fatal). # Delegates to action_setup_otlp.cjs (same file used by actions/setup/index.js) # to keep dev/release and script mode behavior in sync. diff --git a/pkg/constants/feature_constants.go b/pkg/constants/feature_constants.go index 752b8395e8d..e96a397a7be 100644 --- a/pkg/constants/feature_constants.go +++ b/pkg/constants/feature_constants.go @@ -80,4 +80,18 @@ const ( // features: // group-concurrency-queue: false GroupConcurrencyQueueFeatureFlag FeatureFlag = "group-concurrency-queue" + // SetupCopilotResolverFeatureFlag enables the setup action's Copilot CLI + // resolver. When enabled for a Copilot-engine workflow, the setup action + // inspects the runner toolcache (populated by runner-images bake) and, on + // a hit, prepends the cached CLI's bin directory to PATH and sets the + // "copilot-cached" output to "true". The compiler-emitted bash installer + // step is then skipped via `if: steps.setup.outputs.copilot-cached != 'true'`. + // On a miss the bash installer runs as before, so flag-on is strictly + // backward compatible. + // + // Workflow frontmatter usage: + // + // features: + // setup-copilot-resolver: true + SetupCopilotResolverFeatureFlag FeatureFlag = "setup-copilot-resolver" ) diff --git a/pkg/workflow/cache.go b/pkg/workflow/cache.go index 172cff11957..db37211a3db 100644 --- a/pkg/workflow/cache.go +++ b/pkg/workflow/cache.go @@ -960,7 +960,7 @@ func (c *Compiler) buildUpdateCacheMemoryJob(data *WorkflowData, threatDetection // Cache job depends on agent job; reuse the agent's trace ID so all jobs share one OTLP trace cacheTraceID := fmt.Sprintf("${{ needs.%s.outputs.setup-trace-id }}", constants.ActivationJobName) cacheParentSpanID := setupParentSpanNeedsExpr(constants.ActivationJobName) - setupSteps = append(setupSteps, c.generateSetupStep(data, setupActionRef, SetupActionDestination, false, cacheTraceID, cacheParentSpanID)...) + setupSteps = append(setupSteps, c.generateSetupStep(data, setupActionRef, SetupActionDestination, false, cacheTraceID, cacheParentSpanID, false)...) } // Prepend setup steps to all cache steps diff --git a/pkg/workflow/compiler_activation_job_builder.go b/pkg/workflow/compiler_activation_job_builder.go index 54007f009ce..6f619382d9b 100644 --- a/pkg/workflow/compiler_activation_job_builder.go +++ b/pkg/workflow/compiler_activation_job_builder.go @@ -103,7 +103,7 @@ func (c *Compiler) newActivationJobBuildContext( activationSetupTraceID = fmt.Sprintf("${{ needs.%s.outputs.setup-trace-id }}", constants.PreActivationJobName) activationSetupParentSpanID = setupParentSpanNeedsExpr(constants.PreActivationJobName) } - ctx.steps = append(ctx.steps, c.generateSetupStep(ctx.data, setupActionRef, SetupActionDestination, false, activationSetupTraceID, activationSetupParentSpanID)...) + ctx.steps = append(ctx.steps, c.generateSetupStep(ctx.data, setupActionRef, SetupActionDestination, false, activationSetupTraceID, activationSetupParentSpanID, false)...) ctx.outputs["setup-trace-id"] = "${{ steps.setup.outputs.trace-id }}" ctx.outputs["setup-span-id"] = "${{ steps.setup.outputs.span-id }}" ctx.outputs["setup-parent-span-id"] = "${{ steps.setup.outputs.parent-span-id || steps.setup.outputs.span-id }}" diff --git a/pkg/workflow/compiler_experiments.go b/pkg/workflow/compiler_experiments.go index 0f0e319936a..c01e6105ab2 100644 --- a/pkg/workflow/compiler_experiments.go +++ b/pkg/workflow/compiler_experiments.go @@ -578,7 +578,7 @@ func (c *Compiler) buildPushExperimentsStateJob(data *WorkflowData) (*Job, error steps = append(steps, c.generateCheckoutActionsFolder(data)...) traceID := fmt.Sprintf("${{ needs.%s.outputs.setup-trace-id }}", constants.ActivationJobName) parentSpanID := setupParentSpanNeedsExpr(constants.ActivationJobName) - steps = append(steps, c.generateSetupStep(data, setupActionRef, SetupActionDestination, false, traceID, parentSpanID)...) + steps = append(steps, c.generateSetupStep(data, setupActionRef, SetupActionDestination, false, traceID, parentSpanID, false)...) } // Checkout step – configure git credentials without downloading workspace files. diff --git a/pkg/workflow/compiler_main_job.go b/pkg/workflow/compiler_main_job.go index de05b29bd1d..21a0401f06f 100644 --- a/pkg/workflow/compiler_main_job.go +++ b/pkg/workflow/compiler_main_job.go @@ -37,7 +37,7 @@ func (c *Compiler) buildMainJob(data *WorkflowData, activationJobCreated bool) ( // Pass activation's trace ID so all agent spans share the same OTLP trace agentTraceID := fmt.Sprintf("${{ needs.%s.outputs.setup-trace-id }}", constants.ActivationJobName) agentParentSpanID := setupParentSpanNeedsExpr(constants.ActivationJobName) - steps = append(steps, c.generateSetupStep(data, setupActionRef, SetupActionDestination, false, agentTraceID, agentParentSpanID)...) + steps = append(steps, c.generateSetupStep(data, setupActionRef, SetupActionDestination, false, agentTraceID, agentParentSpanID, shouldUseCopilotResolver(data))...) } // Set runtime paths that depend on RUNNER_TEMP via $GITHUB_ENV. diff --git a/pkg/workflow/compiler_pre_activation_job.go b/pkg/workflow/compiler_pre_activation_job.go index c4fc555018e..e79e016d247 100644 --- a/pkg/workflow/compiler_pre_activation_job.go +++ b/pkg/workflow/compiler_pre_activation_job.go @@ -41,7 +41,7 @@ func (c *Compiler) buildPreActivationJob(data *WorkflowData, needsPermissionChec // Pre-activation job doesn't need project support (no safe outputs processed here) // Pre-activation generates the root trace ID; activation will reuse it via setup-trace-id output - steps = append(steps, c.generateSetupStep(data, setupActionRef, SetupActionDestination, false, "", "")...) + steps = append(steps, c.generateSetupStep(data, setupActionRef, SetupActionDestination, false, "", "", false)...) // Determine permissions for pre-activation job var perms *Permissions diff --git a/pkg/workflow/compiler_safe_outputs_job.go b/pkg/workflow/compiler_safe_outputs_job.go index f8eb19a097c..c26c15154c1 100644 --- a/pkg/workflow/compiler_safe_outputs_job.go +++ b/pkg/workflow/compiler_safe_outputs_job.go @@ -89,7 +89,7 @@ func (c *Compiler) buildSafeOutputsSetupAndDownloadSteps(data *WorkflowData, age // Safe outputs job depends on agent job; reuse the agent's trace ID so all jobs share one OTLP trace safeOutputsTraceID := fmt.Sprintf("${{ needs.%s.outputs.setup-trace-id }}", constants.ActivationJobName) safeOutputsParentSpanID := setupParentSpanNeedsExpr(constants.ActivationJobName) - steps = append(steps, c.generateSetupStep(data, setupActionRef, SetupActionDestination, enableArtifactClient, safeOutputsTraceID, safeOutputsParentSpanID)...) + steps = append(steps, c.generateSetupStep(data, setupActionRef, SetupActionDestination, enableArtifactClient, safeOutputsTraceID, safeOutputsParentSpanID, false)...) } // Mask OTLP telemetry headers immediately after setup so authentication tokens cannot @@ -442,7 +442,7 @@ func (c *Compiler) buildSafeOutputsJobFromParts( // Use the same traceID as the real call so the line count matches exactly countTraceID := fmt.Sprintf("${{ needs.%s.outputs.setup-trace-id }}", constants.ActivationJobName) countParentSpanID := setupParentSpanNeedsExpr(constants.ActivationJobName) - insertIndex += len(c.generateSetupStep(data, setupActionRef, SetupActionDestination, data.SafeOutputs != nil && data.SafeOutputs.UploadArtifact != nil, countTraceID, countParentSpanID)) + insertIndex += len(c.generateSetupStep(data, setupActionRef, SetupActionDestination, data.SafeOutputs != nil && data.SafeOutputs.UploadArtifact != nil, countTraceID, countParentSpanID, false)) } // Add artifact download steps count diff --git a/pkg/workflow/compiler_unlock_job.go b/pkg/workflow/compiler_unlock_job.go index 6c91dbffe72..ee64b366872 100644 --- a/pkg/workflow/compiler_unlock_job.go +++ b/pkg/workflow/compiler_unlock_job.go @@ -40,7 +40,7 @@ func (c *Compiler) buildUnlockJob(data *WorkflowData, threatDetectionEnabled boo // Unlock job depends on activation, reuse its trace ID unlockTraceID := fmt.Sprintf("${{ needs.%s.outputs.setup-trace-id }}", constants.ActivationJobName) unlockParentSpanID := setupParentSpanNeedsExpr(constants.ActivationJobName) - steps = append(steps, c.generateSetupStep(data, setupActionRef, SetupActionDestination, false, unlockTraceID, unlockParentSpanID)...) + steps = append(steps, c.generateSetupStep(data, setupActionRef, SetupActionDestination, false, unlockTraceID, unlockParentSpanID, false)...) // Add unlock step // Build condition: only unlock if issue was locked by activation job diff --git a/pkg/workflow/compiler_yaml_step_generation.go b/pkg/workflow/compiler_yaml_step_generation.go index ad797c704fb..e81c84b2648 100644 --- a/pkg/workflow/compiler_yaml_step_generation.go +++ b/pkg/workflow/compiler_yaml_step_generation.go @@ -115,6 +115,7 @@ func (c *Compiler) generateRestoreActionsSetupStep() string { // - enableArtifactClient: Whether to install @actions/artifact so upload_artifact.cjs can upload via REST API directly // - traceID: Optional OTLP trace ID expression for cross-job span correlation (e.g., "${{ needs.activation.outputs.setup-trace-id }}"). Empty string means a new trace ID is generated. // - parentSpanID: Optional OTLP parent span ID expression for setup-span nesting (e.g., setupParentSpanNeedsExpr(constants.ActivationJobName)). Empty string means setup span is emitted as root. +// - installCopilot: Whether the setup action should run its Copilot CLI resolver (toolcache hit → add-path + set copilot-cached=true; miss → no-op). Only set true for jobs that actually run the Copilot engine. // // Returns a slice of strings representing the YAML lines for the setup step. func buildSetupWorkflowRefExpr(data *WorkflowData) string { @@ -128,7 +129,7 @@ func setupParentSpanNeedsExpr(upstreamJob constants.JobName) string { return fmt.Sprintf("${{ needs.%s.outputs.setup-parent-span-id || needs.%s.outputs.setup-span-id }}", upstreamJob, upstreamJob) } -func (c *Compiler) generateSetupStep(data *WorkflowData, setupActionRef string, destination string, enableArtifactClient bool, traceID string, parentSpanID string) []string { +func (c *Compiler) generateSetupStep(data *WorkflowData, setupActionRef string, destination string, enableArtifactClient bool, traceID string, parentSpanID string, installCopilot bool) []string { setupEngineID := "" if data != nil { if data.EngineConfig != nil && data.EngineConfig.ID != "" { @@ -176,11 +177,17 @@ func (c *Compiler) generateSetupStep(data *WorkflowData, setupActionRef string, if enableArtifactClient { lines = append(lines, " INPUT_SAFE_OUTPUT_ARTIFACT_CLIENT: 'true'\n") } + if installCopilot { + lines = append(lines, + " INPUT_INSTALL_COPILOT: 'true'\n", + fmt.Sprintf(" INPUT_GH_AW_VERSION: %q\n", GetVersion()), + ) + } return lines } // Dev/Release mode: use the setup action - compilerYamlStepGenerationLog.Printf("Generating setup step: ref=%s, destination=%s, artifactClient=%t, traceID=%q, parentSpanID=%q", setupActionRef, destination, enableArtifactClient, traceID, parentSpanID) + compilerYamlStepGenerationLog.Printf("Generating setup step: ref=%s, destination=%s, artifactClient=%t, traceID=%q, parentSpanID=%q, installCopilot=%t", setupActionRef, destination, enableArtifactClient, traceID, parentSpanID, installCopilot) lines := []string{ " - name: Setup Scripts\n", " id: setup\n", @@ -218,6 +225,15 @@ func (c *Compiler) generateSetupStep(data *WorkflowData, setupActionRef string, if hasWorkflowCallTrigger(data.On) { lines = append(lines, " GH_AW_SETUP_AW_CONTEXT: ${{ inputs.aw_context }}\n") } + if installCopilot { + // The resolver reads INPUT_INSTALL_COPILOT directly from the step env, so + // no action.yml input declaration is required. This keeps the action's + // input surface unchanged. + lines = append(lines, + " INPUT_INSTALL_COPILOT: 'true'\n", + fmt.Sprintf(" INPUT_GH_AW_VERSION: %q\n", GetVersion()), + ) + } return lines } diff --git a/pkg/workflow/copilot_engine_installation.go b/pkg/workflow/copilot_engine_installation.go index 65009db31d1..c8f5aff99f6 100644 --- a/pkg/workflow/copilot_engine_installation.go +++ b/pkg/workflow/copilot_engine_installation.go @@ -17,6 +17,8 @@ package workflow import ( + "strings" + "github.com/github/gh-aw/pkg/constants" "github.com/github/gh-aw/pkg/logger" ) @@ -93,9 +95,72 @@ func (e *CopilotEngine) GetInstallationSteps(workflowData *WorkflowData) []GitHu // Use the installer script for global installation copilotInstallLog.Print("Using new installer script for Copilot installation") npmSteps := GenerateCopilotInstallerSteps(copilotVersion, "Install GitHub Copilot CLI") + + // When the setup-copilot-resolver feature is enabled, gate the bash installer + // step on the setup action's `copilot-cached` output. On cache hit, the setup + // action already added the cached CLI to PATH and this step is skipped. + // On cache miss, the installer runs as before. + if shouldUseCopilotResolver(workflowData) { + copilotInstallLog.Print("setup-copilot-resolver enabled: gating installer step on steps.setup.outputs.copilot-cached") + npmSteps = gateStepsOnCopilotCached(npmSteps) + } + return BuildNpmEngineInstallStepsWithAWF(npmSteps, workflowData) } +// shouldUseCopilotResolver reports whether the setup action's Copilot CLI +// resolver should be activated for this workflow. It requires both: +// - the workflow's engine to be Copilot (only Copilot uses the toolcache bake), and +// - the SetupCopilotResolverFeatureFlag to be enabled (default off until validated). +// +// Used by: +// - GetInstallationSteps (this file): gate the bash installer step +// - compiler_main_job.go: pass installCopilot=true to generateSetupStep +// - threat_detection.go: pass installCopilot=true to generateSetupStep +func shouldUseCopilotResolver(workflowData *WorkflowData) bool { + if workflowData == nil { + return false + } + engineID := "" + if workflowData.EngineConfig != nil && workflowData.EngineConfig.ID != "" { + engineID = workflowData.EngineConfig.ID + } else if workflowData.AI != "" { + engineID = workflowData.AI + } + if engineID != "copilot" { + return false + } + return isFeatureEnabled(constants.SetupCopilotResolverFeatureFlag, workflowData) +} + +// gateStepsOnCopilotCached injects `if: steps.setup.outputs.copilot-cached != 'true'` +// into each step's YAML so the bash installer is skipped when the resolver hit +// the toolcache. The `if:` line is inserted directly after the `- name:` line +// (which is conventionally the first line of each step emitted by +// GenerateCopilotInstallerSteps). +// +// Steps without a recognisable ` - name:` opener are returned unmodified; +// any future refactor of the installer step shape should re-verify this here. +func gateStepsOnCopilotCached(steps []GitHubActionStep) []GitHubActionStep { + const condition = " if: steps.setup.outputs.copilot-cached != 'true'" + out := make([]GitHubActionStep, 0, len(steps)) + for _, step := range steps { + if len(step) == 0 { + out = append(out, step) + continue + } + if !strings.HasPrefix(step[0], " - name:") { + out = append(out, step) + continue + } + gated := make([]string, 0, len(step)+1) + gated = append(gated, step[0], condition) + gated = append(gated, step[1:]...) + out = append(out, GitHubActionStep(gated)) + } + return out +} + // generateAWFInstallationStep creates a GitHub Actions step to install the AWF binary // with SHA256 checksum verification to protect against supply chain attacks. // diff --git a/pkg/workflow/notify_comment.go b/pkg/workflow/notify_comment.go index 53a069c48be..386ac0ab176 100644 --- a/pkg/workflow/notify_comment.go +++ b/pkg/workflow/notify_comment.go @@ -47,7 +47,7 @@ func (c *Compiler) buildConclusionJob(data *WorkflowData, mainJobName string, sa // Conclusion/notify job depends on activation, reuse its trace ID notifyTraceID := fmt.Sprintf("${{ needs.%s.outputs.setup-trace-id }}", constants.ActivationJobName) notifyParentSpanID := setupParentSpanNeedsExpr(constants.ActivationJobName) - steps = append(steps, c.generateSetupStep(data, setupActionRef, SetupActionDestination, false, notifyTraceID, notifyParentSpanID)...) + steps = append(steps, c.generateSetupStep(data, setupActionRef, SetupActionDestination, false, notifyTraceID, notifyParentSpanID, false)...) } // Add GitHub App token minting step if app is configured diff --git a/pkg/workflow/publish_assets.go b/pkg/workflow/publish_assets.go index e84be99dfd1..0eef16df924 100644 --- a/pkg/workflow/publish_assets.go +++ b/pkg/workflow/publish_assets.go @@ -139,7 +139,7 @@ func (c *Compiler) buildUploadAssetsJob(data *WorkflowData, mainJobName string, // Publish assets job depends on the agent job; reuse its trace ID so all jobs share one OTLP trace publishTraceID := fmt.Sprintf("${{ needs.%s.outputs.setup-trace-id }}", constants.ActivationJobName) publishParentSpanID := setupParentSpanNeedsExpr(constants.ActivationJobName) - preSteps = append(preSteps, c.generateSetupStep(data, setupActionRef, SetupActionDestination, false, publishTraceID, publishParentSpanID)...) + preSteps = append(preSteps, c.generateSetupStep(data, setupActionRef, SetupActionDestination, false, publishTraceID, publishParentSpanID, false)...) } // Step 1: Checkout repository diff --git a/pkg/workflow/repo_memory.go b/pkg/workflow/repo_memory.go index ac04a733e3a..c9a64556366 100644 --- a/pkg/workflow/repo_memory.go +++ b/pkg/workflow/repo_memory.go @@ -625,7 +625,7 @@ func (c *Compiler) buildPushRepoMemoryJob(data *WorkflowData, threatDetectionEna // Repo memory job depends on agent job; reuse the agent's trace ID so all jobs share one OTLP trace repoMemoryTraceID := fmt.Sprintf("${{ needs.%s.outputs.setup-trace-id }}", constants.ActivationJobName) repoMemoryParentSpanID := setupParentSpanNeedsExpr(constants.ActivationJobName) - steps = append(steps, c.generateSetupStep(data, setupActionRef, SetupActionDestination, false, repoMemoryTraceID, repoMemoryParentSpanID)...) + steps = append(steps, c.generateSetupStep(data, setupActionRef, SetupActionDestination, false, repoMemoryTraceID, repoMemoryParentSpanID, false)...) } // Add checkout step to configure git (without checking out files) diff --git a/pkg/workflow/setup_step_version_test.go b/pkg/workflow/setup_step_version_test.go index e139a936c3b..103ecd01602 100644 --- a/pkg/workflow/setup_step_version_test.go +++ b/pkg/workflow/setup_step_version_test.go @@ -123,7 +123,7 @@ func TestGenerateSetupStepIncludesVersion(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { c := NewCompiler() - lines := c.generateSetupStep(tt.data, "github/gh-aw/actions/setup@abc123", "${{ runner.temp }}/gh-aw", false, "", "") + lines := c.generateSetupStep(tt.data, "github/gh-aw/actions/setup@abc123", "${{ runner.temp }}/gh-aw", false, "", "", false) combined := strings.Join(lines, "") if tt.noVersionLine { @@ -208,7 +208,7 @@ func TestGenerateSetupStepIncludesAWFVersion(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { c := NewCompiler() - lines := c.generateSetupStep(tt.data, "github/gh-aw/actions/setup@abc123", "${{ runner.temp }}/gh-aw", false, "", "") + lines := c.generateSetupStep(tt.data, "github/gh-aw/actions/setup@abc123", "${{ runner.temp }}/gh-aw", false, "", "", false) combined := strings.Join(lines, "") if tt.expectNoAWFLine { @@ -231,7 +231,7 @@ func TestGenerateSetupStepIncludesParentSpanID(t *testing.T) { data := &WorkflowData{Name: "my-workflow"} parentExpr := "${{ needs.activation.outputs.setup-span-id }}" - lines := c.generateSetupStep(data, "github/gh-aw/actions/setup@abc123", "${{ runner.temp }}/gh-aw", false, "", parentExpr) + lines := c.generateSetupStep(data, "github/gh-aw/actions/setup@abc123", "${{ runner.temp }}/gh-aw", false, "", parentExpr, false) combined := strings.Join(lines, "") if !strings.Contains(combined, "parent-span-id: "+parentExpr) { @@ -246,7 +246,7 @@ func TestGenerateSetupStepIncludesEngineID(t *testing.T) { EngineConfig: &EngineConfig{ID: "copilot"}, } - lines := c.generateSetupStep(data, "github/gh-aw/actions/setup@abc123", "${{ runner.temp }}/gh-aw", false, "", "") + lines := c.generateSetupStep(data, "github/gh-aw/actions/setup@abc123", "${{ runner.temp }}/gh-aw", false, "", "", false) combined := strings.Join(lines, "") if !strings.Contains(combined, `GH_AW_INFO_ENGINE_ID: "copilot"`) { @@ -262,7 +262,7 @@ func TestGenerateSetupStepIncludesEngineIDInScriptModeFromAIField(t *testing.T) AI: "claude", } - lines := c.generateSetupStep(data, "github/gh-aw/actions/setup@abc123", "${{ runner.temp }}/gh-aw", false, "", "") + lines := c.generateSetupStep(data, "github/gh-aw/actions/setup@abc123", "${{ runner.temp }}/gh-aw", false, "", "", false) combined := strings.Join(lines, "") if !strings.Contains(combined, `GH_AW_INFO_ENGINE_ID: "claude"`) { diff --git a/pkg/workflow/threat_detection.go b/pkg/workflow/threat_detection.go index af81cb8824b..4c6769961ef 100644 --- a/pkg/workflow/threat_detection.go +++ b/pkg/workflow/threat_detection.go @@ -887,7 +887,7 @@ func (c *Compiler) buildDetectionJob(data *WorkflowData) (*Job, error) { // Detection job depends on agent job; reuse the agent's trace ID so all jobs share one OTLP trace detectionTraceID := fmt.Sprintf("${{ needs.%s.outputs.setup-trace-id }}", constants.ActivationJobName) detectionParentSpanID := setupParentSpanNeedsExpr(constants.ActivationJobName) - steps = append(steps, c.generateSetupStep(data, setupActionRef, SetupActionDestination, false, detectionTraceID, detectionParentSpanID)...) + steps = append(steps, c.generateSetupStep(data, setupActionRef, SetupActionDestination, false, detectionTraceID, detectionParentSpanID, shouldUseCopilotResolver(data))...) } // Download agent output artifact to access output files (prompt.txt, agent_output.json, patches). From 3026304364f1cef0f1578fa6c52f69be8fadb824 Mon Sep 17 00:00:00 2001 From: Salman Muin Kayser Chishti Date: Tue, 26 May 2026 13:47:42 +0000 Subject: [PATCH 2/6] use native fetch for live matrix Node 24 ships fetch globally, so the manual https.get + chunk buffer + timeout wiring isn't needed. Replace it with fetch + AbortSignal.timeout, matching the convention in check_version_updates.cjs and send_otlp_span.cjs. Same contract: returns parsed JSON on 2xx + valid JSON, returns null on any error (network, timeout, non-200, parse failure). Never throws. --- actions/setup/js/install_copilot_cli.cjs | 63 +++++------------------- 1 file changed, 11 insertions(+), 52 deletions(-) diff --git a/actions/setup/js/install_copilot_cli.cjs b/actions/setup/js/install_copilot_cli.cjs index f1e6350c5fb..c12bc9ca0a6 100644 --- a/actions/setup/js/install_copilot_cli.cjs +++ b/actions/setup/js/install_copilot_cli.cjs @@ -20,7 +20,6 @@ const fs = require("fs"); const path = require("path"); -const https = require("https"); const COMPAT_URL = "https://raw.githubusercontent.com/github/gh-aw-actions/main/.github/aw/compat.json"; const FETCH_TIMEOUT_MS = 5000; @@ -68,58 +67,18 @@ function rowMatchesGhAw(row, ghAwSemver) { // Fetch the live matrix from gh-aw-actions. Resolves to parsed JSON or null // on any error (network, timeout, non-200, malformed JSON). Never throws. -function fetchLiveMatrix() { - return new Promise(resolve => { - let settled = false; - const done = val => { - if (settled) return; - settled = true; - resolve(val); - }; - let req; - try { - req = https.get(COMPAT_URL, { timeout: FETCH_TIMEOUT_MS }, res => { - if (res.statusCode !== 200) { - res.resume(); - logErr(`live matrix fetch returned HTTP ${res.statusCode}`); - return done(null); - } - let body = ""; - res.setEncoding("utf8"); - res.on("data", chunk => { - body += chunk; - if (body.length > 1_000_000) { - req.destroy(); - done(null); - } - }); - res.on("end", () => { - try { - done(JSON.parse(body)); - } catch (e) { - logErr(`live matrix parse failed: ${e.message}`); - done(null); - } - }); - res.on("error", e => { - logErr(`live matrix stream error: ${e.message}`); - done(null); - }); - }); - req.on("timeout", () => { - req.destroy(); - logErr("live matrix fetch timed out"); - done(null); - }); - req.on("error", e => { - logErr(`live matrix request error: ${e.message}`); - done(null); - }); - } catch (e) { - logErr(`live matrix request setup failed: ${e.message}`); - done(null); +async function fetchLiveMatrix() { + try { + const res = await fetch(COMPAT_URL, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }); + if (!res.ok) { + logErr(`live matrix fetch returned HTTP ${res.status}`); + return null; } - }); + return JSON.parse(await res.text()); + } catch (e) { + logErr(`live matrix fetch failed: ${e.message}`); + return null; + } } // Load the bundled fallback matrix from disk, resolved via __dirname so script From fb6b0ef0fb90af254127b986a55eaaae4732ba03 Mon Sep 17 00:00:00 2001 From: Salman Muin Kayser Chishti Date: Tue, 26 May 2026 14:17:06 +0000 Subject: [PATCH 3/6] remove feature flag; resolver runs unconditionally for Copilot workflows The setup-copilot-resolver feature flag added a layer of plumbing more complex than the value it provided. Drop the flag, the boolean parameter on generateSetupStep, and the dedicated INPUT_INSTALL_COPILOT env. The compiler now emits INPUT_GH_AW_VERSION for any Copilot-engine workflow, which setup.sh treats as the signal to invoke the resolver. The bash installer step gate (skip on copilot-cached=true) is now always applied for Copilot workflows, which is a no-op when the resolver reports a miss. Non-Copilot workflows are unchanged: no env var emitted, no resolver run, no golden diff. --- actions/setup/action.yml | 2 +- actions/setup/js/install_copilot_cli.cjs | 2 +- actions/setup/setup.sh | 20 +++++----- pkg/constants/feature_constants.go | 14 ------- pkg/workflow/cache.go | 2 +- .../compiler_activation_job_builder.go | 2 +- pkg/workflow/compiler_experiments.go | 2 +- pkg/workflow/compiler_main_job.go | 2 +- pkg/workflow/compiler_pre_activation_job.go | 2 +- pkg/workflow/compiler_safe_outputs_job.go | 4 +- pkg/workflow/compiler_unlock_job.go | 2 +- pkg/workflow/compiler_yaml_step_generation.go | 20 +++++----- pkg/workflow/copilot_engine_installation.go | 39 +++---------------- pkg/workflow/notify_comment.go | 2 +- pkg/workflow/publish_assets.go | 2 +- pkg/workflow/repo_memory.go | 2 +- pkg/workflow/setup_step_version_test.go | 10 ++--- .../TestWasmGolden_AllEngines/copilot.golden | 4 ++ .../basic-copilot.golden | 4 ++ .../playwright-cli-mode.golden | 4 ++ .../smoke-copilot.golden | 4 ++ .../with-imports.golden | 4 ++ pkg/workflow/threat_detection.go | 2 +- 23 files changed, 66 insertions(+), 85 deletions(-) diff --git a/actions/setup/action.yml b/actions/setup/action.yml index 9ce7fe8c8ef..cc2b2de9356 100644 --- a/actions/setup/action.yml +++ b/actions/setup/action.yml @@ -33,7 +33,7 @@ outputs: parent-span-id: description: 'The OTLP parent span ID used for the gh-aw..setup span. Pass this through downstream jobs so all setup spans share the same global parent span.' copilot-cached: - description: 'Set to "true" when the Copilot CLI resolver found a cached, gh-aw-compatible version in the runner tool cache and added it to PATH. Set to "false" (or unset) when no cached version was usable. Compiler-emitted installer steps gate themselves on this output. Only populated when the workflow opts in via the setup-copilot-resolver feature flag.' + description: 'Set to "true" when the Copilot CLI resolver found a cached, gh-aw-compatible version in the runner tool cache and added it to PATH. Set to "false" (or unset) when no cached version was usable. Compiler-emitted installer steps gate themselves on this output. Only populated for Copilot-engine workflows.' copilot-path: description: 'Absolute path to the directory added to PATH when copilot-cached=true. Empty otherwise. Useful for diagnostics.' diff --git a/actions/setup/js/install_copilot_cli.cjs b/actions/setup/js/install_copilot_cli.cjs index c12bc9ca0a6..661007a85e0 100644 --- a/actions/setup/js/install_copilot_cli.cjs +++ b/actions/setup/js/install_copilot_cli.cjs @@ -1,6 +1,6 @@ // install_copilot_cli.cjs — zero-dependency Copilot CLI resolver // -// Runs from actions/setup/setup.sh when the compiler emits INPUT_INSTALL_COPILOT=true. +// Runs from actions/setup/setup.sh for Copilot-engine workflows. // Looks for a cached, gh-aw-compatible build of @github/copilot in the runner // tool cache. On a hit, appends the bin directory to $GITHUB_PATH and writes // `copilot-cached=true` / `copilot-path=` to $GITHUB_OUTPUT so the diff --git a/actions/setup/setup.sh b/actions/setup/setup.sh index 52223d103ed..c2f3201fb77 100755 --- a/actions/setup/setup.sh +++ b/actions/setup/setup.sh @@ -434,16 +434,18 @@ else debug_log "Artifact client not enabled - skipping @actions/artifact installation" fi -# Resolve a cached, gh-aw-compatible Copilot CLI from the runner tool cache when -# requested by the compiler. The resolver writes the `copilot-cached` step output -# and, on a cache hit, appends its tool-cache bin directory to $GITHUB_PATH. -# This runs *before* the OTLP span so that the resolver's outcome is visible in -# the same step. The resolver never throws: any error is logged and treated as a -# cache miss, allowing the workflow's existing bash installer step to run. -if [ "${INPUT_INSTALL_COPILOT:-false}" = "true" ]; then +# Resolve a cached, gh-aw-compatible Copilot CLI from the runner tool cache. +# The compiler sets INPUT_GH_AW_VERSION only for Copilot-engine workflows, so +# treat its presence as the trigger. The resolver writes the `copilot-cached` +# step output and, on a cache hit, appends its tool-cache bin directory to +# $GITHUB_PATH. This runs *before* the OTLP span so that the resolver's +# outcome is visible in the same step. The resolver never throws: any error +# is logged and treated as a cache miss, allowing the workflow's existing +# bash installer step to run. +if [ -n "${INPUT_GH_AW_VERSION:-}" ]; then if command -v node &>/dev/null && [ -f "${SCRIPT_DIR}/js/install_copilot_cli.cjs" ]; then - debug_log "Running Copilot CLI resolver (gh-aw-version=${INPUT_GH_AW_VERSION:-unknown})..." - INPUT_GH_AW_VERSION="${INPUT_GH_AW_VERSION:-}" node "${SCRIPT_DIR}/js/install_copilot_cli.cjs" || true + debug_log "Running Copilot CLI resolver (gh-aw-version=${INPUT_GH_AW_VERSION})..." + INPUT_GH_AW_VERSION="${INPUT_GH_AW_VERSION}" node "${SCRIPT_DIR}/js/install_copilot_cli.cjs" || true debug_log "Copilot CLI resolver step complete" else debug_log "Copilot CLI resolver skipped: node or resolver script unavailable" diff --git a/pkg/constants/feature_constants.go b/pkg/constants/feature_constants.go index e96a397a7be..752b8395e8d 100644 --- a/pkg/constants/feature_constants.go +++ b/pkg/constants/feature_constants.go @@ -80,18 +80,4 @@ const ( // features: // group-concurrency-queue: false GroupConcurrencyQueueFeatureFlag FeatureFlag = "group-concurrency-queue" - // SetupCopilotResolverFeatureFlag enables the setup action's Copilot CLI - // resolver. When enabled for a Copilot-engine workflow, the setup action - // inspects the runner toolcache (populated by runner-images bake) and, on - // a hit, prepends the cached CLI's bin directory to PATH and sets the - // "copilot-cached" output to "true". The compiler-emitted bash installer - // step is then skipped via `if: steps.setup.outputs.copilot-cached != 'true'`. - // On a miss the bash installer runs as before, so flag-on is strictly - // backward compatible. - // - // Workflow frontmatter usage: - // - // features: - // setup-copilot-resolver: true - SetupCopilotResolverFeatureFlag FeatureFlag = "setup-copilot-resolver" ) diff --git a/pkg/workflow/cache.go b/pkg/workflow/cache.go index db37211a3db..172cff11957 100644 --- a/pkg/workflow/cache.go +++ b/pkg/workflow/cache.go @@ -960,7 +960,7 @@ func (c *Compiler) buildUpdateCacheMemoryJob(data *WorkflowData, threatDetection // Cache job depends on agent job; reuse the agent's trace ID so all jobs share one OTLP trace cacheTraceID := fmt.Sprintf("${{ needs.%s.outputs.setup-trace-id }}", constants.ActivationJobName) cacheParentSpanID := setupParentSpanNeedsExpr(constants.ActivationJobName) - setupSteps = append(setupSteps, c.generateSetupStep(data, setupActionRef, SetupActionDestination, false, cacheTraceID, cacheParentSpanID, false)...) + setupSteps = append(setupSteps, c.generateSetupStep(data, setupActionRef, SetupActionDestination, false, cacheTraceID, cacheParentSpanID)...) } // Prepend setup steps to all cache steps diff --git a/pkg/workflow/compiler_activation_job_builder.go b/pkg/workflow/compiler_activation_job_builder.go index 6f619382d9b..54007f009ce 100644 --- a/pkg/workflow/compiler_activation_job_builder.go +++ b/pkg/workflow/compiler_activation_job_builder.go @@ -103,7 +103,7 @@ func (c *Compiler) newActivationJobBuildContext( activationSetupTraceID = fmt.Sprintf("${{ needs.%s.outputs.setup-trace-id }}", constants.PreActivationJobName) activationSetupParentSpanID = setupParentSpanNeedsExpr(constants.PreActivationJobName) } - ctx.steps = append(ctx.steps, c.generateSetupStep(ctx.data, setupActionRef, SetupActionDestination, false, activationSetupTraceID, activationSetupParentSpanID, false)...) + ctx.steps = append(ctx.steps, c.generateSetupStep(ctx.data, setupActionRef, SetupActionDestination, false, activationSetupTraceID, activationSetupParentSpanID)...) ctx.outputs["setup-trace-id"] = "${{ steps.setup.outputs.trace-id }}" ctx.outputs["setup-span-id"] = "${{ steps.setup.outputs.span-id }}" ctx.outputs["setup-parent-span-id"] = "${{ steps.setup.outputs.parent-span-id || steps.setup.outputs.span-id }}" diff --git a/pkg/workflow/compiler_experiments.go b/pkg/workflow/compiler_experiments.go index c01e6105ab2..0f0e319936a 100644 --- a/pkg/workflow/compiler_experiments.go +++ b/pkg/workflow/compiler_experiments.go @@ -578,7 +578,7 @@ func (c *Compiler) buildPushExperimentsStateJob(data *WorkflowData) (*Job, error steps = append(steps, c.generateCheckoutActionsFolder(data)...) traceID := fmt.Sprintf("${{ needs.%s.outputs.setup-trace-id }}", constants.ActivationJobName) parentSpanID := setupParentSpanNeedsExpr(constants.ActivationJobName) - steps = append(steps, c.generateSetupStep(data, setupActionRef, SetupActionDestination, false, traceID, parentSpanID, false)...) + steps = append(steps, c.generateSetupStep(data, setupActionRef, SetupActionDestination, false, traceID, parentSpanID)...) } // Checkout step – configure git credentials without downloading workspace files. diff --git a/pkg/workflow/compiler_main_job.go b/pkg/workflow/compiler_main_job.go index 21a0401f06f..de05b29bd1d 100644 --- a/pkg/workflow/compiler_main_job.go +++ b/pkg/workflow/compiler_main_job.go @@ -37,7 +37,7 @@ func (c *Compiler) buildMainJob(data *WorkflowData, activationJobCreated bool) ( // Pass activation's trace ID so all agent spans share the same OTLP trace agentTraceID := fmt.Sprintf("${{ needs.%s.outputs.setup-trace-id }}", constants.ActivationJobName) agentParentSpanID := setupParentSpanNeedsExpr(constants.ActivationJobName) - steps = append(steps, c.generateSetupStep(data, setupActionRef, SetupActionDestination, false, agentTraceID, agentParentSpanID, shouldUseCopilotResolver(data))...) + steps = append(steps, c.generateSetupStep(data, setupActionRef, SetupActionDestination, false, agentTraceID, agentParentSpanID)...) } // Set runtime paths that depend on RUNNER_TEMP via $GITHUB_ENV. diff --git a/pkg/workflow/compiler_pre_activation_job.go b/pkg/workflow/compiler_pre_activation_job.go index e79e016d247..c4fc555018e 100644 --- a/pkg/workflow/compiler_pre_activation_job.go +++ b/pkg/workflow/compiler_pre_activation_job.go @@ -41,7 +41,7 @@ func (c *Compiler) buildPreActivationJob(data *WorkflowData, needsPermissionChec // Pre-activation job doesn't need project support (no safe outputs processed here) // Pre-activation generates the root trace ID; activation will reuse it via setup-trace-id output - steps = append(steps, c.generateSetupStep(data, setupActionRef, SetupActionDestination, false, "", "", false)...) + steps = append(steps, c.generateSetupStep(data, setupActionRef, SetupActionDestination, false, "", "")...) // Determine permissions for pre-activation job var perms *Permissions diff --git a/pkg/workflow/compiler_safe_outputs_job.go b/pkg/workflow/compiler_safe_outputs_job.go index c26c15154c1..f8eb19a097c 100644 --- a/pkg/workflow/compiler_safe_outputs_job.go +++ b/pkg/workflow/compiler_safe_outputs_job.go @@ -89,7 +89,7 @@ func (c *Compiler) buildSafeOutputsSetupAndDownloadSteps(data *WorkflowData, age // Safe outputs job depends on agent job; reuse the agent's trace ID so all jobs share one OTLP trace safeOutputsTraceID := fmt.Sprintf("${{ needs.%s.outputs.setup-trace-id }}", constants.ActivationJobName) safeOutputsParentSpanID := setupParentSpanNeedsExpr(constants.ActivationJobName) - steps = append(steps, c.generateSetupStep(data, setupActionRef, SetupActionDestination, enableArtifactClient, safeOutputsTraceID, safeOutputsParentSpanID, false)...) + steps = append(steps, c.generateSetupStep(data, setupActionRef, SetupActionDestination, enableArtifactClient, safeOutputsTraceID, safeOutputsParentSpanID)...) } // Mask OTLP telemetry headers immediately after setup so authentication tokens cannot @@ -442,7 +442,7 @@ func (c *Compiler) buildSafeOutputsJobFromParts( // Use the same traceID as the real call so the line count matches exactly countTraceID := fmt.Sprintf("${{ needs.%s.outputs.setup-trace-id }}", constants.ActivationJobName) countParentSpanID := setupParentSpanNeedsExpr(constants.ActivationJobName) - insertIndex += len(c.generateSetupStep(data, setupActionRef, SetupActionDestination, data.SafeOutputs != nil && data.SafeOutputs.UploadArtifact != nil, countTraceID, countParentSpanID, false)) + insertIndex += len(c.generateSetupStep(data, setupActionRef, SetupActionDestination, data.SafeOutputs != nil && data.SafeOutputs.UploadArtifact != nil, countTraceID, countParentSpanID)) } // Add artifact download steps count diff --git a/pkg/workflow/compiler_unlock_job.go b/pkg/workflow/compiler_unlock_job.go index ee64b366872..6c91dbffe72 100644 --- a/pkg/workflow/compiler_unlock_job.go +++ b/pkg/workflow/compiler_unlock_job.go @@ -40,7 +40,7 @@ func (c *Compiler) buildUnlockJob(data *WorkflowData, threatDetectionEnabled boo // Unlock job depends on activation, reuse its trace ID unlockTraceID := fmt.Sprintf("${{ needs.%s.outputs.setup-trace-id }}", constants.ActivationJobName) unlockParentSpanID := setupParentSpanNeedsExpr(constants.ActivationJobName) - steps = append(steps, c.generateSetupStep(data, setupActionRef, SetupActionDestination, false, unlockTraceID, unlockParentSpanID, false)...) + steps = append(steps, c.generateSetupStep(data, setupActionRef, SetupActionDestination, false, unlockTraceID, unlockParentSpanID)...) // Add unlock step // Build condition: only unlock if issue was locked by activation job diff --git a/pkg/workflow/compiler_yaml_step_generation.go b/pkg/workflow/compiler_yaml_step_generation.go index e81c84b2648..f99e37de5dc 100644 --- a/pkg/workflow/compiler_yaml_step_generation.go +++ b/pkg/workflow/compiler_yaml_step_generation.go @@ -115,7 +115,9 @@ func (c *Compiler) generateRestoreActionsSetupStep() string { // - enableArtifactClient: Whether to install @actions/artifact so upload_artifact.cjs can upload via REST API directly // - traceID: Optional OTLP trace ID expression for cross-job span correlation (e.g., "${{ needs.activation.outputs.setup-trace-id }}"). Empty string means a new trace ID is generated. // - parentSpanID: Optional OTLP parent span ID expression for setup-span nesting (e.g., setupParentSpanNeedsExpr(constants.ActivationJobName)). Empty string means setup span is emitted as root. -// - installCopilot: Whether the setup action should run its Copilot CLI resolver (toolcache hit → add-path + set copilot-cached=true; miss → no-op). Only set true for jobs that actually run the Copilot engine. +// +// For Copilot-engine workflows, the setup step receives INPUT_GH_AW_VERSION, +// which triggers setup.sh to invoke the Copilot CLI toolcache resolver. // // Returns a slice of strings representing the YAML lines for the setup step. func buildSetupWorkflowRefExpr(data *WorkflowData) string { @@ -129,7 +131,7 @@ func setupParentSpanNeedsExpr(upstreamJob constants.JobName) string { return fmt.Sprintf("${{ needs.%s.outputs.setup-parent-span-id || needs.%s.outputs.setup-span-id }}", upstreamJob, upstreamJob) } -func (c *Compiler) generateSetupStep(data *WorkflowData, setupActionRef string, destination string, enableArtifactClient bool, traceID string, parentSpanID string, installCopilot bool) []string { +func (c *Compiler) generateSetupStep(data *WorkflowData, setupActionRef string, destination string, enableArtifactClient bool, traceID string, parentSpanID string) []string { setupEngineID := "" if data != nil { if data.EngineConfig != nil && data.EngineConfig.ID != "" { @@ -177,9 +179,8 @@ func (c *Compiler) generateSetupStep(data *WorkflowData, setupActionRef string, if enableArtifactClient { lines = append(lines, " INPUT_SAFE_OUTPUT_ARTIFACT_CLIENT: 'true'\n") } - if installCopilot { + if setupEngineID == "copilot" { lines = append(lines, - " INPUT_INSTALL_COPILOT: 'true'\n", fmt.Sprintf(" INPUT_GH_AW_VERSION: %q\n", GetVersion()), ) } @@ -187,7 +188,7 @@ func (c *Compiler) generateSetupStep(data *WorkflowData, setupActionRef string, } // Dev/Release mode: use the setup action - compilerYamlStepGenerationLog.Printf("Generating setup step: ref=%s, destination=%s, artifactClient=%t, traceID=%q, parentSpanID=%q, installCopilot=%t", setupActionRef, destination, enableArtifactClient, traceID, parentSpanID, installCopilot) + compilerYamlStepGenerationLog.Printf("Generating setup step: ref=%s, destination=%s, artifactClient=%t, traceID=%q, parentSpanID=%q, engineID=%q", setupActionRef, destination, enableArtifactClient, traceID, parentSpanID, setupEngineID) lines := []string{ " - name: Setup Scripts\n", " id: setup\n", @@ -225,12 +226,11 @@ func (c *Compiler) generateSetupStep(data *WorkflowData, setupActionRef string, if hasWorkflowCallTrigger(data.On) { lines = append(lines, " GH_AW_SETUP_AW_CONTEXT: ${{ inputs.aw_context }}\n") } - if installCopilot { - // The resolver reads INPUT_INSTALL_COPILOT directly from the step env, so - // no action.yml input declaration is required. This keeps the action's - // input surface unchanged. + if setupEngineID == "copilot" { + // The resolver reads INPUT_GH_AW_VERSION directly from the step env, + // so no action.yml input declaration is required. This keeps the + // action's input surface unchanged. lines = append(lines, - " INPUT_INSTALL_COPILOT: 'true'\n", fmt.Sprintf(" INPUT_GH_AW_VERSION: %q\n", GetVersion()), ) } diff --git a/pkg/workflow/copilot_engine_installation.go b/pkg/workflow/copilot_engine_installation.go index c8f5aff99f6..609f49db77b 100644 --- a/pkg/workflow/copilot_engine_installation.go +++ b/pkg/workflow/copilot_engine_installation.go @@ -96,43 +96,16 @@ func (e *CopilotEngine) GetInstallationSteps(workflowData *WorkflowData) []GitHu copilotInstallLog.Print("Using new installer script for Copilot installation") npmSteps := GenerateCopilotInstallerSteps(copilotVersion, "Install GitHub Copilot CLI") - // When the setup-copilot-resolver feature is enabled, gate the bash installer - // step on the setup action's `copilot-cached` output. On cache hit, the setup - // action already added the cached CLI to PATH and this step is skipped. - // On cache miss, the installer runs as before. - if shouldUseCopilotResolver(workflowData) { - copilotInstallLog.Print("setup-copilot-resolver enabled: gating installer step on steps.setup.outputs.copilot-cached") - npmSteps = gateStepsOnCopilotCached(npmSteps) - } + // Gate the bash installer step on the setup action's `copilot-cached` + // output. On cache hit, the setup action already added the cached CLI to + // PATH and this step is skipped. On cache miss, the installer runs as + // before. + copilotInstallLog.Print("Gating installer step on steps.setup.outputs.copilot-cached") + npmSteps = gateStepsOnCopilotCached(npmSteps) return BuildNpmEngineInstallStepsWithAWF(npmSteps, workflowData) } -// shouldUseCopilotResolver reports whether the setup action's Copilot CLI -// resolver should be activated for this workflow. It requires both: -// - the workflow's engine to be Copilot (only Copilot uses the toolcache bake), and -// - the SetupCopilotResolverFeatureFlag to be enabled (default off until validated). -// -// Used by: -// - GetInstallationSteps (this file): gate the bash installer step -// - compiler_main_job.go: pass installCopilot=true to generateSetupStep -// - threat_detection.go: pass installCopilot=true to generateSetupStep -func shouldUseCopilotResolver(workflowData *WorkflowData) bool { - if workflowData == nil { - return false - } - engineID := "" - if workflowData.EngineConfig != nil && workflowData.EngineConfig.ID != "" { - engineID = workflowData.EngineConfig.ID - } else if workflowData.AI != "" { - engineID = workflowData.AI - } - if engineID != "copilot" { - return false - } - return isFeatureEnabled(constants.SetupCopilotResolverFeatureFlag, workflowData) -} - // gateStepsOnCopilotCached injects `if: steps.setup.outputs.copilot-cached != 'true'` // into each step's YAML so the bash installer is skipped when the resolver hit // the toolcache. The `if:` line is inserted directly after the `- name:` line diff --git a/pkg/workflow/notify_comment.go b/pkg/workflow/notify_comment.go index 386ac0ab176..53a069c48be 100644 --- a/pkg/workflow/notify_comment.go +++ b/pkg/workflow/notify_comment.go @@ -47,7 +47,7 @@ func (c *Compiler) buildConclusionJob(data *WorkflowData, mainJobName string, sa // Conclusion/notify job depends on activation, reuse its trace ID notifyTraceID := fmt.Sprintf("${{ needs.%s.outputs.setup-trace-id }}", constants.ActivationJobName) notifyParentSpanID := setupParentSpanNeedsExpr(constants.ActivationJobName) - steps = append(steps, c.generateSetupStep(data, setupActionRef, SetupActionDestination, false, notifyTraceID, notifyParentSpanID, false)...) + steps = append(steps, c.generateSetupStep(data, setupActionRef, SetupActionDestination, false, notifyTraceID, notifyParentSpanID)...) } // Add GitHub App token minting step if app is configured diff --git a/pkg/workflow/publish_assets.go b/pkg/workflow/publish_assets.go index 0eef16df924..e84be99dfd1 100644 --- a/pkg/workflow/publish_assets.go +++ b/pkg/workflow/publish_assets.go @@ -139,7 +139,7 @@ func (c *Compiler) buildUploadAssetsJob(data *WorkflowData, mainJobName string, // Publish assets job depends on the agent job; reuse its trace ID so all jobs share one OTLP trace publishTraceID := fmt.Sprintf("${{ needs.%s.outputs.setup-trace-id }}", constants.ActivationJobName) publishParentSpanID := setupParentSpanNeedsExpr(constants.ActivationJobName) - preSteps = append(preSteps, c.generateSetupStep(data, setupActionRef, SetupActionDestination, false, publishTraceID, publishParentSpanID, false)...) + preSteps = append(preSteps, c.generateSetupStep(data, setupActionRef, SetupActionDestination, false, publishTraceID, publishParentSpanID)...) } // Step 1: Checkout repository diff --git a/pkg/workflow/repo_memory.go b/pkg/workflow/repo_memory.go index c9a64556366..ac04a733e3a 100644 --- a/pkg/workflow/repo_memory.go +++ b/pkg/workflow/repo_memory.go @@ -625,7 +625,7 @@ func (c *Compiler) buildPushRepoMemoryJob(data *WorkflowData, threatDetectionEna // Repo memory job depends on agent job; reuse the agent's trace ID so all jobs share one OTLP trace repoMemoryTraceID := fmt.Sprintf("${{ needs.%s.outputs.setup-trace-id }}", constants.ActivationJobName) repoMemoryParentSpanID := setupParentSpanNeedsExpr(constants.ActivationJobName) - steps = append(steps, c.generateSetupStep(data, setupActionRef, SetupActionDestination, false, repoMemoryTraceID, repoMemoryParentSpanID, false)...) + steps = append(steps, c.generateSetupStep(data, setupActionRef, SetupActionDestination, false, repoMemoryTraceID, repoMemoryParentSpanID)...) } // Add checkout step to configure git (without checking out files) diff --git a/pkg/workflow/setup_step_version_test.go b/pkg/workflow/setup_step_version_test.go index 103ecd01602..e139a936c3b 100644 --- a/pkg/workflow/setup_step_version_test.go +++ b/pkg/workflow/setup_step_version_test.go @@ -123,7 +123,7 @@ func TestGenerateSetupStepIncludesVersion(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { c := NewCompiler() - lines := c.generateSetupStep(tt.data, "github/gh-aw/actions/setup@abc123", "${{ runner.temp }}/gh-aw", false, "", "", false) + lines := c.generateSetupStep(tt.data, "github/gh-aw/actions/setup@abc123", "${{ runner.temp }}/gh-aw", false, "", "") combined := strings.Join(lines, "") if tt.noVersionLine { @@ -208,7 +208,7 @@ func TestGenerateSetupStepIncludesAWFVersion(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { c := NewCompiler() - lines := c.generateSetupStep(tt.data, "github/gh-aw/actions/setup@abc123", "${{ runner.temp }}/gh-aw", false, "", "", false) + lines := c.generateSetupStep(tt.data, "github/gh-aw/actions/setup@abc123", "${{ runner.temp }}/gh-aw", false, "", "") combined := strings.Join(lines, "") if tt.expectNoAWFLine { @@ -231,7 +231,7 @@ func TestGenerateSetupStepIncludesParentSpanID(t *testing.T) { data := &WorkflowData{Name: "my-workflow"} parentExpr := "${{ needs.activation.outputs.setup-span-id }}" - lines := c.generateSetupStep(data, "github/gh-aw/actions/setup@abc123", "${{ runner.temp }}/gh-aw", false, "", parentExpr, false) + lines := c.generateSetupStep(data, "github/gh-aw/actions/setup@abc123", "${{ runner.temp }}/gh-aw", false, "", parentExpr) combined := strings.Join(lines, "") if !strings.Contains(combined, "parent-span-id: "+parentExpr) { @@ -246,7 +246,7 @@ func TestGenerateSetupStepIncludesEngineID(t *testing.T) { EngineConfig: &EngineConfig{ID: "copilot"}, } - lines := c.generateSetupStep(data, "github/gh-aw/actions/setup@abc123", "${{ runner.temp }}/gh-aw", false, "", "", false) + lines := c.generateSetupStep(data, "github/gh-aw/actions/setup@abc123", "${{ runner.temp }}/gh-aw", false, "", "") combined := strings.Join(lines, "") if !strings.Contains(combined, `GH_AW_INFO_ENGINE_ID: "copilot"`) { @@ -262,7 +262,7 @@ func TestGenerateSetupStepIncludesEngineIDInScriptModeFromAIField(t *testing.T) AI: "claude", } - lines := c.generateSetupStep(data, "github/gh-aw/actions/setup@abc123", "${{ runner.temp }}/gh-aw", false, "", "", false) + lines := c.generateSetupStep(data, "github/gh-aw/actions/setup@abc123", "${{ runner.temp }}/gh-aw", false, "", "") combined := strings.Join(lines, "") if !strings.Contains(combined, `GH_AW_INFO_ENGINE_ID: "claude"`) { diff --git a/pkg/workflow/testdata/TestWasmGolden_AllEngines/copilot.golden b/pkg/workflow/testdata/TestWasmGolden_AllEngines/copilot.golden index b056eb53edc..5621d4c717f 100644 --- a/pkg/workflow/testdata/TestWasmGolden_AllEngines/copilot.golden +++ b/pkg/workflow/testdata/TestWasmGolden_AllEngines/copilot.golden @@ -56,6 +56,7 @@ jobs: GH_AW_INFO_VERSION: "1.0.52" GH_AW_INFO_AWF_VERSION: "v0.25.55" GH_AW_INFO_ENGINE_ID: "copilot" + INPUT_GH_AW_VERSION: "dev" - name: Generate agentic run info id: generate_aw_info env: @@ -296,6 +297,7 @@ jobs: GH_AW_INFO_VERSION: "1.0.52" GH_AW_INFO_AWF_VERSION: "v0.25.55" GH_AW_INFO_ENGINE_ID: "copilot" + INPUT_GH_AW_VERSION: "dev" - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: @@ -334,6 +336,7 @@ jobs: const { main } = require('${{ runner.temp }}/gh-aw/actions/checkout_pr_branch.cjs'); await main(); - name: Install GitHub Copilot CLI + if: steps.setup.outputs.copilot-cached != 'true' run: bash "${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh" 1.0.52 env: GH_HOST: github.com @@ -643,6 +646,7 @@ jobs: GH_AW_INFO_VERSION: "1.0.52" GH_AW_INFO_AWF_VERSION: "v0.25.55" GH_AW_INFO_ENGINE_ID: "copilot" + INPUT_GH_AW_VERSION: "dev" - name: Check team membership for workflow id: check_membership uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 diff --git a/pkg/workflow/testdata/TestWasmGolden_CompileFixtures/basic-copilot.golden b/pkg/workflow/testdata/TestWasmGolden_CompileFixtures/basic-copilot.golden index 27eab4e2f17..89109ae36a4 100644 --- a/pkg/workflow/testdata/TestWasmGolden_CompileFixtures/basic-copilot.golden +++ b/pkg/workflow/testdata/TestWasmGolden_CompileFixtures/basic-copilot.golden @@ -56,6 +56,7 @@ jobs: GH_AW_INFO_VERSION: "1.0.52" GH_AW_INFO_AWF_VERSION: "v0.25.55" GH_AW_INFO_ENGINE_ID: "copilot" + INPUT_GH_AW_VERSION: "dev" - name: Generate agentic run info id: generate_aw_info env: @@ -296,6 +297,7 @@ jobs: GH_AW_INFO_VERSION: "1.0.52" GH_AW_INFO_AWF_VERSION: "v0.25.55" GH_AW_INFO_ENGINE_ID: "copilot" + INPUT_GH_AW_VERSION: "dev" - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: @@ -334,6 +336,7 @@ jobs: const { main } = require('${{ runner.temp }}/gh-aw/actions/checkout_pr_branch.cjs'); await main(); - name: Install GitHub Copilot CLI + if: steps.setup.outputs.copilot-cached != 'true' run: bash "${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh" 1.0.52 env: GH_HOST: github.com @@ -643,6 +646,7 @@ jobs: GH_AW_INFO_VERSION: "1.0.52" GH_AW_INFO_AWF_VERSION: "v0.25.55" GH_AW_INFO_ENGINE_ID: "copilot" + INPUT_GH_AW_VERSION: "dev" - name: Check team membership for workflow id: check_membership uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 diff --git a/pkg/workflow/testdata/TestWasmGolden_CompileFixtures/playwright-cli-mode.golden b/pkg/workflow/testdata/TestWasmGolden_CompileFixtures/playwright-cli-mode.golden index e8e197e300d..d4c54edacc1 100644 --- a/pkg/workflow/testdata/TestWasmGolden_CompileFixtures/playwright-cli-mode.golden +++ b/pkg/workflow/testdata/TestWasmGolden_CompileFixtures/playwright-cli-mode.golden @@ -56,6 +56,7 @@ jobs: GH_AW_INFO_VERSION: "1.0.52" GH_AW_INFO_AWF_VERSION: "v0.25.55" GH_AW_INFO_ENGINE_ID: "copilot" + INPUT_GH_AW_VERSION: "dev" - name: Generate agentic run info id: generate_aw_info env: @@ -306,6 +307,7 @@ jobs: GH_AW_INFO_VERSION: "1.0.52" GH_AW_INFO_AWF_VERSION: "v0.25.55" GH_AW_INFO_ENGINE_ID: "copilot" + INPUT_GH_AW_VERSION: "dev" - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: @@ -344,6 +346,7 @@ jobs: const { main } = require('${{ runner.temp }}/gh-aw/actions/checkout_pr_branch.cjs'); await main(); - name: Install GitHub Copilot CLI + if: steps.setup.outputs.copilot-cached != 'true' run: bash "${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh" 1.0.52 env: GH_HOST: github.com @@ -659,6 +662,7 @@ jobs: GH_AW_INFO_VERSION: "1.0.52" GH_AW_INFO_AWF_VERSION: "v0.25.55" GH_AW_INFO_ENGINE_ID: "copilot" + INPUT_GH_AW_VERSION: "dev" - name: Check team membership for workflow id: check_membership uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 diff --git a/pkg/workflow/testdata/TestWasmGolden_CompileFixtures/smoke-copilot.golden b/pkg/workflow/testdata/TestWasmGolden_CompileFixtures/smoke-copilot.golden index 740b3cb7475..1e885e27c10 100644 --- a/pkg/workflow/testdata/TestWasmGolden_CompileFixtures/smoke-copilot.golden +++ b/pkg/workflow/testdata/TestWasmGolden_CompileFixtures/smoke-copilot.golden @@ -70,6 +70,7 @@ jobs: GH_AW_INFO_VERSION: "1.0.52" GH_AW_INFO_AWF_VERSION: "v0.25.55" GH_AW_INFO_ENGINE_ID: "copilot" + INPUT_GH_AW_VERSION: "dev" - name: Generate agentic run info id: generate_aw_info env: @@ -421,6 +422,7 @@ jobs: GH_AW_INFO_VERSION: "1.0.52" GH_AW_INFO_AWF_VERSION: "v0.25.55" GH_AW_INFO_ENGINE_ID: "copilot" + INPUT_GH_AW_VERSION: "dev" - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: @@ -496,6 +498,7 @@ jobs: const { main } = require('${{ runner.temp }}/gh-aw/actions/checkout_pr_branch.cjs'); await main(); - name: Install GitHub Copilot CLI + if: steps.setup.outputs.copilot-cached != 'true' run: bash "${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh" 1.0.52 env: GH_HOST: github.com @@ -902,6 +905,7 @@ jobs: GH_AW_INFO_VERSION: "1.0.52" GH_AW_INFO_AWF_VERSION: "v0.25.55" GH_AW_INFO_ENGINE_ID: "copilot" + INPUT_GH_AW_VERSION: "dev" - name: Check team membership for workflow id: check_membership uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 diff --git a/pkg/workflow/testdata/TestWasmGolden_CompileFixtures/with-imports.golden b/pkg/workflow/testdata/TestWasmGolden_CompileFixtures/with-imports.golden index 331dedce996..497df36d579 100644 --- a/pkg/workflow/testdata/TestWasmGolden_CompileFixtures/with-imports.golden +++ b/pkg/workflow/testdata/TestWasmGolden_CompileFixtures/with-imports.golden @@ -56,6 +56,7 @@ jobs: GH_AW_INFO_VERSION: "1.0.52" GH_AW_INFO_AWF_VERSION: "v0.25.55" GH_AW_INFO_ENGINE_ID: "copilot" + INPUT_GH_AW_VERSION: "dev" - name: Generate agentic run info id: generate_aw_info env: @@ -297,6 +298,7 @@ jobs: GH_AW_INFO_VERSION: "1.0.52" GH_AW_INFO_AWF_VERSION: "v0.25.55" GH_AW_INFO_ENGINE_ID: "copilot" + INPUT_GH_AW_VERSION: "dev" - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: @@ -335,6 +337,7 @@ jobs: const { main } = require('${{ runner.temp }}/gh-aw/actions/checkout_pr_branch.cjs'); await main(); - name: Install GitHub Copilot CLI + if: steps.setup.outputs.copilot-cached != 'true' run: bash "${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh" 1.0.52 env: GH_HOST: github.com @@ -644,6 +647,7 @@ jobs: GH_AW_INFO_VERSION: "1.0.52" GH_AW_INFO_AWF_VERSION: "v0.25.55" GH_AW_INFO_ENGINE_ID: "copilot" + INPUT_GH_AW_VERSION: "dev" - name: Check team membership for workflow id: check_membership uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 diff --git a/pkg/workflow/threat_detection.go b/pkg/workflow/threat_detection.go index 4c6769961ef..af81cb8824b 100644 --- a/pkg/workflow/threat_detection.go +++ b/pkg/workflow/threat_detection.go @@ -887,7 +887,7 @@ func (c *Compiler) buildDetectionJob(data *WorkflowData) (*Job, error) { // Detection job depends on agent job; reuse the agent's trace ID so all jobs share one OTLP trace detectionTraceID := fmt.Sprintf("${{ needs.%s.outputs.setup-trace-id }}", constants.ActivationJobName) detectionParentSpanID := setupParentSpanNeedsExpr(constants.ActivationJobName) - steps = append(steps, c.generateSetupStep(data, setupActionRef, SetupActionDestination, false, detectionTraceID, detectionParentSpanID, shouldUseCopilotResolver(data))...) + steps = append(steps, c.generateSetupStep(data, setupActionRef, SetupActionDestination, false, detectionTraceID, detectionParentSpanID)...) } // Download agent output artifact to access output files (prompt.txt, agent_output.json, patches). From 8a55a3a848d5af9d50e43a4379418401ccfd4862 Mon Sep 17 00:00:00 2001 From: Salman Muin Kayser Chishti Date: Tue, 26 May 2026 14:29:59 +0000 Subject: [PATCH 4/6] use core.info/warning via shim; add @ts-check + JSDoc annotations - Require shim.cjs for global core/context so logging routes through core.info / core.warning instead of console.log / console.error, matching the convention used across the other setup/js modules. - Add // @ts-check directive (now passes tsc --noEmit with the existing strictNullChecks config) and JSDoc type annotations on every exported function plus the ParsedSemver and CompatRow shared typedefs. - Tighten error narrowing in catch blocks (Error instance guards) and add explicit casts where the matrix payload is treated as unknown. Resolver tests (23/23) and full typecheck still pass. --- actions/setup/js/install_copilot_cli.cjs | 144 ++++++++++++++++++----- 1 file changed, 112 insertions(+), 32 deletions(-) diff --git a/actions/setup/js/install_copilot_cli.cjs b/actions/setup/js/install_copilot_cli.cjs index 661007a85e0..39caed50e00 100644 --- a/actions/setup/js/install_copilot_cli.cjs +++ b/actions/setup/js/install_copilot_cli.cjs @@ -1,3 +1,4 @@ +// @ts-check // install_copilot_cli.cjs — zero-dependency Copilot CLI resolver // // Runs from actions/setup/setup.sh for Copilot-engine workflows. @@ -18,22 +19,49 @@ // Matrix entry format (see actions/setup/compat.json): // { "max-gh-aw": "*"|, "min-agent": , "max-agent": } +require("./shim.cjs"); + const fs = require("fs"); const path = require("path"); const COMPAT_URL = "https://raw.githubusercontent.com/github/gh-aw-actions/main/.github/aw/compat.json"; const FETCH_TIMEOUT_MS = 5000; +/** + * @param {string} msg + */ function log(msg) { - console.log(`[install_copilot_cli] ${msg}`); + core.info(`[install_copilot_cli] ${msg}`); } +/** + * @param {string} msg + */ function logErr(msg) { - console.error(`[install_copilot_cli] ${msg}`); + core.warning(`[install_copilot_cli] ${msg}`); } -// Parse a SemVer string into a comparable tuple. Returns null on malformed -// input so callers can skip the entry rather than crash. +/** + * Parsed semantic version: `[major, minor, patch, prerelease]`. + * `prerelease` is `""` when absent. + * @typedef {[number, number, number, string]} ParsedSemver + */ + +/** + * Compat matrix row. + * @typedef {{ + * "max-gh-aw": string, + * "min-agent": string, + * "max-agent": string, + * }} CompatRow + */ + +/** + * Parse a SemVer string into a comparable tuple. Returns null on malformed + * input so callers can skip the entry rather than crash. + * @param {unknown} v + * @returns {ParsedSemver | null} + */ function parseSemver(v) { if (typeof v !== "string") return null; const m = v.match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/); @@ -41,8 +69,13 @@ function parseSemver(v) { return [Number(m[1]), Number(m[2]), Number(m[3]), m[4] || ""]; } -// Compare two parsed SemVers. Returns -1/0/1. Treats any pre-release as lower -// than its release counterpart (sufficient for our pinning use case). +/** + * Compare two parsed SemVers. Returns -1/0/1. Treats any pre-release as lower + * than its release counterpart (sufficient for our pinning use case). + * @param {ParsedSemver} a + * @param {ParsedSemver} b + * @returns {-1 | 0 | 1} + */ function cmpSemver(a, b) { for (let i = 0; i < 3; i++) { if (a[i] !== b[i]) return a[i] < b[i] ? -1 : 1; @@ -53,11 +86,16 @@ function cmpSemver(a, b) { return a[3] < b[3] ? -1 : 1; } -// Does the matrix row's `max-gh-aw` cover the current gh-aw compiler version? -// "*" always matches. Otherwise the compiler version must be <= max-gh-aw. -// Unparseable compiler versions (e.g., "dev") are treated as matching only "*". +/** + * Does the matrix row's `max-gh-aw` cover the current gh-aw compiler version? + * "*" always matches. Otherwise the compiler version must be <= max-gh-aw. + * Unparseable compiler versions (e.g., "dev") are treated as matching only "*". + * @param {unknown} row + * @param {ParsedSemver | null} ghAwSemver + * @returns {boolean} + */ function rowMatchesGhAw(row, ghAwSemver) { - const maxGhAw = row && row["max-gh-aw"]; + const maxGhAw = row && typeof row === "object" ? /** @type {Record} */ (row)["max-gh-aw"] : undefined; if (maxGhAw === "*") return true; if (!ghAwSemver) return false; const max = parseSemver(maxGhAw); @@ -65,8 +103,11 @@ function rowMatchesGhAw(row, ghAwSemver) { return cmpSemver(ghAwSemver, max) <= 0; } -// Fetch the live matrix from gh-aw-actions. Resolves to parsed JSON or null -// on any error (network, timeout, non-200, malformed JSON). Never throws. +/** + * Fetch the live matrix from gh-aw-actions. Resolves to parsed JSON or null + * on any error (network, timeout, non-200, malformed JSON). Never throws. + * @returns {Promise} + */ async function fetchLiveMatrix() { try { const res = await fetch(COMPAT_URL, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }); @@ -76,36 +117,48 @@ async function fetchLiveMatrix() { } return JSON.parse(await res.text()); } catch (e) { - logErr(`live matrix fetch failed: ${e.message}`); + logErr(`live matrix fetch failed: ${e instanceof Error ? e.message : String(e)}`); return null; } } -// Load the bundled fallback matrix from disk, resolved via __dirname so script -// mode (running from /tmp/gh-aw/actions-source/...) and dev/release mode both -// find it next to the setup action. +/** + * Load the bundled fallback matrix from disk, resolved via __dirname so script + * mode (running from /tmp/gh-aw/actions-source/...) and dev/release mode both + * find it next to the setup action. + * @returns {unknown} + */ function loadBundledMatrix() { try { const p = path.join(__dirname, "..", "compat.json"); return JSON.parse(fs.readFileSync(p, "utf8")); } catch (e) { - logErr(`bundled matrix load failed: ${e.message}`); + logErr(`bundled matrix load failed: ${e instanceof Error ? e.message : String(e)}`); return null; } } -// Extract the copilot row list from a matrix document. Returns [] if the -// document is malformed (treated as "no compatible versions"). +/** + * Extract the copilot row list from a matrix document. Returns [] if the + * document is malformed (treated as "no compatible versions"). + * @param {unknown} matrix + * @returns {CompatRow[]} + */ function copilotRows(matrix) { if (!matrix || typeof matrix !== "object") return []; - const v1 = matrix["agent-compat-v1"]; + const v1 = /** @type {Record} */ (matrix)["agent-compat-v1"]; if (!v1 || typeof v1 !== "object") return []; - const rows = v1["copilot"]; + const rows = /** @type {Record} */ (v1)["copilot"]; return Array.isArray(rows) ? rows : []; } -// Pick the resolution range [min, max] from the first row whose max-gh-aw -// covers the current compiler version. Returns null when no row matches. +/** + * Pick the resolution range [min, max] from the first row whose max-gh-aw + * covers the current compiler version. Returns null when no row matches. + * @param {CompatRow[]} rows + * @param {ParsedSemver | null} ghAwSemver + * @returns {{min: ParsedSemver, max: ParsedSemver} | null} + */ function pickRange(rows, ghAwSemver) { for (const row of rows) { if (!rowMatchesGhAw(row, ghAwSemver)) continue; @@ -117,7 +170,10 @@ function pickRange(rows, ghAwSemver) { return null; } -// Map process.arch to the runner-images tool-cache arch directory name. +/** + * Map process.arch to the runner-images tool-cache arch directory name. + * @returns {string} + */ function detectArch() { switch (process.arch) { case "x64": @@ -129,19 +185,29 @@ function detectArch() { } } -// Find the highest cached Copilot CLI version in [min, max] under the runner -// tool cache. Returns { version, dir, binDir } on hit, null on miss. Only -// considers entries with a sibling .complete marker (matches @actions/tool-cache). +/** + * Find the highest cached Copilot CLI version in [min, max] under the runner + * tool cache. Returns { version, dir, binDir } on hit, null on miss. Only + * considers entries with a sibling .complete marker (matches @actions/tool-cache). + * @param {string} toolCacheRoot + * @param {string} arch + * @param {{min: ParsedSemver, max: ParsedSemver}} range + * @returns {{version: string, dir: string, binDir: string} | null} + */ function findCachedCopilot(toolCacheRoot, arch, range) { const baseDir = path.join(toolCacheRoot, "copilot-cli"); + /** @type {string[]} */ let entries; try { entries = fs.readdirSync(baseDir); } catch (e) { - if (e.code !== "ENOENT") logErr(`tool cache scan failed: ${e.message}`); + if (/** @type {NodeJS.ErrnoException} */ (e).code !== "ENOENT") { + logErr(`tool cache scan failed: ${e instanceof Error ? e.message : String(e)}`); + } return null; } + /** @type {{parsed: ParsedSemver, version: string, dir: string, binDir: string} | null} */ let best = null; for (const entry of entries) { const v = parseSemver(entry); @@ -166,27 +232,41 @@ function findCachedCopilot(toolCacheRoot, arch, range) { return { version: best.version, dir: best.dir, binDir: best.binDir }; } -// Append a line to a GitHub Actions runner file (e.g., $GITHUB_PATH or -// $GITHUB_OUTPUT). No-ops when the path env var is unset so the resolver runs -// in local tests without polluting the workflow. +/** + * Append a line to a GitHub Actions runner file (e.g., $GITHUB_PATH or + * $GITHUB_OUTPUT). No-ops when the path env var is unset so the resolver runs + * in local tests without polluting the workflow. + * @param {string} envVar + * @param {string} line + */ function appendRunnerFile(envVar, line) { const p = process.env[envVar]; if (!p) return; try { fs.appendFileSync(p, line.endsWith("\n") ? line : line + "\n", "utf8"); } catch (e) { - logErr(`failed to append to ${envVar}: ${e.message}`); + logErr(`failed to append to ${envVar}: ${e instanceof Error ? e.message : String(e)}`); } } +/** + * @param {string} name + * @param {string} value + */ function writeOutput(name, value) { appendRunnerFile("GITHUB_OUTPUT", `${name}=${value}`); } +/** + * @param {string} dir + */ function addToPath(dir) { appendRunnerFile("GITHUB_PATH", dir); } +/** + * @returns {Promise} + */ async function resolve() { const ghAwVersionRaw = process.env.INPUT_GH_AW_VERSION || ""; const ghAwSemver = parseSemver(ghAwVersionRaw); From d877cac798664349e25ce3f69fd5470328b9d7a4 Mon Sep 17 00:00:00 2001 From: Salman Muin Kayser Chishti Date: Tue, 26 May 2026 14:36:15 +0000 Subject: [PATCH 5/6] style: apply prettier formatting to install_copilot_cli.cjs --- actions/setup/js/install_copilot_cli.cjs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/actions/setup/js/install_copilot_cli.cjs b/actions/setup/js/install_copilot_cli.cjs index 39caed50e00..266835e0e5c 100644 --- a/actions/setup/js/install_copilot_cli.cjs +++ b/actions/setup/js/install_copilot_cli.cjs @@ -95,7 +95,7 @@ function cmpSemver(a, b) { * @returns {boolean} */ function rowMatchesGhAw(row, ghAwSemver) { - const maxGhAw = row && typeof row === "object" ? /** @type {Record} */ (row)["max-gh-aw"] : undefined; + const maxGhAw = row && typeof row === "object" ? /** @type {Record} */ row["max-gh-aw"] : undefined; if (maxGhAw === "*") return true; if (!ghAwSemver) return false; const max = parseSemver(maxGhAw); @@ -146,9 +146,9 @@ function loadBundledMatrix() { */ function copilotRows(matrix) { if (!matrix || typeof matrix !== "object") return []; - const v1 = /** @type {Record} */ (matrix)["agent-compat-v1"]; + const v1 = /** @type {Record} */ matrix["agent-compat-v1"]; if (!v1 || typeof v1 !== "object") return []; - const rows = /** @type {Record} */ (v1)["copilot"]; + const rows = /** @type {Record} */ v1["copilot"]; return Array.isArray(rows) ? rows : []; } @@ -201,7 +201,7 @@ function findCachedCopilot(toolCacheRoot, arch, range) { try { entries = fs.readdirSync(baseDir); } catch (e) { - if (/** @type {NodeJS.ErrnoException} */ (e).code !== "ENOENT") { + if (/** @type {NodeJS.ErrnoException} */ e.code !== "ENOENT") { logErr(`tool cache scan failed: ${e instanceof Error ? e.message : String(e)}`); } return null; From c8065980119a3d3dfa23d72f0f37bc48a2475103 Mon Sep 17 00:00:00 2001 From: Salman Muin Kayser Chishti Date: Tue, 26 May 2026 15:05:10 +0000 Subject: [PATCH 6/6] scope toolcache resolver to agent + detection jobs Re-introduce INPUT_INSTALL_COPILOT as the explicit opt-in for the toolcache resolver in actions/setup/setup.sh. The compiler emits this flag (alongside INPUT_GH_AW_VERSION) on the setup step env block only for jobs that actually invoke the Copilot CLI: the main agent job and the threat-detection job. Other jobs that share the setup action (activation, pre-activation, cache, unlock, safe-outputs, notify-comment, publish-assets, repo-memory, experiments) opt out, so the resolver stays a no-op there. Adds TestGenerateSetupStepEmitsInstallCopilotGate covering the three cases: copilot engine + opt-in emits both env vars; copilot engine without opt-in suppresses them; non-copilot engine ignores the opt-in. --- actions/setup/action.yml | 2 +- actions/setup/setup.sh | 24 +++---- pkg/workflow/cache.go | 2 +- .../compiler_activation_job_builder.go | 2 +- pkg/workflow/compiler_experiments.go | 2 +- pkg/workflow/compiler_main_job.go | 2 +- pkg/workflow/compiler_pre_activation_job.go | 2 +- pkg/workflow/compiler_safe_outputs_job.go | 4 +- pkg/workflow/compiler_unlock_job.go | 2 +- pkg/workflow/compiler_yaml_step_generation.go | 29 +++++--- pkg/workflow/notify_comment.go | 2 +- pkg/workflow/publish_assets.go | 2 +- pkg/workflow/repo_memory.go | 2 +- pkg/workflow/setup_step_version_test.go | 66 +++++++++++++++++-- .../TestWasmGolden_AllEngines/copilot.golden | 3 +- .../basic-copilot.golden | 3 +- .../playwright-cli-mode.golden | 3 +- .../smoke-copilot.golden | 3 +- .../with-imports.golden | 3 +- pkg/workflow/threat_detection.go | 2 +- 20 files changed, 113 insertions(+), 47 deletions(-) diff --git a/actions/setup/action.yml b/actions/setup/action.yml index cc2b2de9356..131237b9168 100644 --- a/actions/setup/action.yml +++ b/actions/setup/action.yml @@ -33,7 +33,7 @@ outputs: parent-span-id: description: 'The OTLP parent span ID used for the gh-aw..setup span. Pass this through downstream jobs so all setup spans share the same global parent span.' copilot-cached: - description: 'Set to "true" when the Copilot CLI resolver found a cached, gh-aw-compatible version in the runner tool cache and added it to PATH. Set to "false" (or unset) when no cached version was usable. Compiler-emitted installer steps gate themselves on this output. Only populated for Copilot-engine workflows.' + description: 'Set to "true" when the Copilot CLI resolver found a cached, gh-aw-compatible version in the runner tool cache and added it to PATH. Set to "false" (or unset) when no cached version was usable. Compiler-emitted installer steps gate themselves on this output. Only populated when the compiler opts the step in by setting INPUT_INSTALL_COPILOT=true (the main agent and threat-detection jobs of Copilot-engine workflows).' copilot-path: description: 'Absolute path to the directory added to PATH when copilot-cached=true. Empty otherwise. Useful for diagnostics.' diff --git a/actions/setup/setup.sh b/actions/setup/setup.sh index c2f3201fb77..1bd150ba18d 100755 --- a/actions/setup/setup.sh +++ b/actions/setup/setup.sh @@ -434,18 +434,20 @@ else debug_log "Artifact client not enabled - skipping @actions/artifact installation" fi -# Resolve a cached, gh-aw-compatible Copilot CLI from the runner tool cache. -# The compiler sets INPUT_GH_AW_VERSION only for Copilot-engine workflows, so -# treat its presence as the trigger. The resolver writes the `copilot-cached` -# step output and, on a cache hit, appends its tool-cache bin directory to -# $GITHUB_PATH. This runs *before* the OTLP span so that the resolver's -# outcome is visible in the same step. The resolver never throws: any error -# is logged and treated as a cache miss, allowing the workflow's existing -# bash installer step to run. -if [ -n "${INPUT_GH_AW_VERSION:-}" ]; then +# Resolve a cached, gh-aw-compatible Copilot CLI from the runner tool cache when +# the compiler explicitly opted this job in via INPUT_INSTALL_COPILOT=true. The +# compiler only sets this flag for jobs that actually invoke the Copilot CLI +# (the main agent job and the threat-detection job, when the Copilot engine is +# selected). INPUT_GH_AW_VERSION is the compatibility-matrix selector consumed +# by the resolver itself. The resolver writes the `copilot-cached` step output +# and, on a cache hit, appends its tool-cache bin directory to $GITHUB_PATH. +# This runs *before* the OTLP span so that the resolver's outcome is visible in +# the same step. The resolver never throws: any error is logged and treated as +# a cache miss, allowing the workflow's existing bash installer step to run. +if [ "${INPUT_INSTALL_COPILOT:-false}" = "true" ]; then if command -v node &>/dev/null && [ -f "${SCRIPT_DIR}/js/install_copilot_cli.cjs" ]; then - debug_log "Running Copilot CLI resolver (gh-aw-version=${INPUT_GH_AW_VERSION})..." - INPUT_GH_AW_VERSION="${INPUT_GH_AW_VERSION}" node "${SCRIPT_DIR}/js/install_copilot_cli.cjs" || true + debug_log "Running Copilot CLI resolver (gh-aw-version=${INPUT_GH_AW_VERSION:-unknown})..." + INPUT_GH_AW_VERSION="${INPUT_GH_AW_VERSION:-}" node "${SCRIPT_DIR}/js/install_copilot_cli.cjs" || true debug_log "Copilot CLI resolver step complete" else debug_log "Copilot CLI resolver skipped: node or resolver script unavailable" diff --git a/pkg/workflow/cache.go b/pkg/workflow/cache.go index 172cff11957..db37211a3db 100644 --- a/pkg/workflow/cache.go +++ b/pkg/workflow/cache.go @@ -960,7 +960,7 @@ func (c *Compiler) buildUpdateCacheMemoryJob(data *WorkflowData, threatDetection // Cache job depends on agent job; reuse the agent's trace ID so all jobs share one OTLP trace cacheTraceID := fmt.Sprintf("${{ needs.%s.outputs.setup-trace-id }}", constants.ActivationJobName) cacheParentSpanID := setupParentSpanNeedsExpr(constants.ActivationJobName) - setupSteps = append(setupSteps, c.generateSetupStep(data, setupActionRef, SetupActionDestination, false, cacheTraceID, cacheParentSpanID)...) + setupSteps = append(setupSteps, c.generateSetupStep(data, setupActionRef, SetupActionDestination, false, cacheTraceID, cacheParentSpanID, false)...) } // Prepend setup steps to all cache steps diff --git a/pkg/workflow/compiler_activation_job_builder.go b/pkg/workflow/compiler_activation_job_builder.go index 54007f009ce..6f619382d9b 100644 --- a/pkg/workflow/compiler_activation_job_builder.go +++ b/pkg/workflow/compiler_activation_job_builder.go @@ -103,7 +103,7 @@ func (c *Compiler) newActivationJobBuildContext( activationSetupTraceID = fmt.Sprintf("${{ needs.%s.outputs.setup-trace-id }}", constants.PreActivationJobName) activationSetupParentSpanID = setupParentSpanNeedsExpr(constants.PreActivationJobName) } - ctx.steps = append(ctx.steps, c.generateSetupStep(ctx.data, setupActionRef, SetupActionDestination, false, activationSetupTraceID, activationSetupParentSpanID)...) + ctx.steps = append(ctx.steps, c.generateSetupStep(ctx.data, setupActionRef, SetupActionDestination, false, activationSetupTraceID, activationSetupParentSpanID, false)...) ctx.outputs["setup-trace-id"] = "${{ steps.setup.outputs.trace-id }}" ctx.outputs["setup-span-id"] = "${{ steps.setup.outputs.span-id }}" ctx.outputs["setup-parent-span-id"] = "${{ steps.setup.outputs.parent-span-id || steps.setup.outputs.span-id }}" diff --git a/pkg/workflow/compiler_experiments.go b/pkg/workflow/compiler_experiments.go index 0f0e319936a..c01e6105ab2 100644 --- a/pkg/workflow/compiler_experiments.go +++ b/pkg/workflow/compiler_experiments.go @@ -578,7 +578,7 @@ func (c *Compiler) buildPushExperimentsStateJob(data *WorkflowData) (*Job, error steps = append(steps, c.generateCheckoutActionsFolder(data)...) traceID := fmt.Sprintf("${{ needs.%s.outputs.setup-trace-id }}", constants.ActivationJobName) parentSpanID := setupParentSpanNeedsExpr(constants.ActivationJobName) - steps = append(steps, c.generateSetupStep(data, setupActionRef, SetupActionDestination, false, traceID, parentSpanID)...) + steps = append(steps, c.generateSetupStep(data, setupActionRef, SetupActionDestination, false, traceID, parentSpanID, false)...) } // Checkout step – configure git credentials without downloading workspace files. diff --git a/pkg/workflow/compiler_main_job.go b/pkg/workflow/compiler_main_job.go index de05b29bd1d..a491555626f 100644 --- a/pkg/workflow/compiler_main_job.go +++ b/pkg/workflow/compiler_main_job.go @@ -37,7 +37,7 @@ func (c *Compiler) buildMainJob(data *WorkflowData, activationJobCreated bool) ( // Pass activation's trace ID so all agent spans share the same OTLP trace agentTraceID := fmt.Sprintf("${{ needs.%s.outputs.setup-trace-id }}", constants.ActivationJobName) agentParentSpanID := setupParentSpanNeedsExpr(constants.ActivationJobName) - steps = append(steps, c.generateSetupStep(data, setupActionRef, SetupActionDestination, false, agentTraceID, agentParentSpanID)...) + steps = append(steps, c.generateSetupStep(data, setupActionRef, SetupActionDestination, false, agentTraceID, agentParentSpanID, true)...) } // Set runtime paths that depend on RUNNER_TEMP via $GITHUB_ENV. diff --git a/pkg/workflow/compiler_pre_activation_job.go b/pkg/workflow/compiler_pre_activation_job.go index c4fc555018e..e79e016d247 100644 --- a/pkg/workflow/compiler_pre_activation_job.go +++ b/pkg/workflow/compiler_pre_activation_job.go @@ -41,7 +41,7 @@ func (c *Compiler) buildPreActivationJob(data *WorkflowData, needsPermissionChec // Pre-activation job doesn't need project support (no safe outputs processed here) // Pre-activation generates the root trace ID; activation will reuse it via setup-trace-id output - steps = append(steps, c.generateSetupStep(data, setupActionRef, SetupActionDestination, false, "", "")...) + steps = append(steps, c.generateSetupStep(data, setupActionRef, SetupActionDestination, false, "", "", false)...) // Determine permissions for pre-activation job var perms *Permissions diff --git a/pkg/workflow/compiler_safe_outputs_job.go b/pkg/workflow/compiler_safe_outputs_job.go index f8eb19a097c..c26c15154c1 100644 --- a/pkg/workflow/compiler_safe_outputs_job.go +++ b/pkg/workflow/compiler_safe_outputs_job.go @@ -89,7 +89,7 @@ func (c *Compiler) buildSafeOutputsSetupAndDownloadSteps(data *WorkflowData, age // Safe outputs job depends on agent job; reuse the agent's trace ID so all jobs share one OTLP trace safeOutputsTraceID := fmt.Sprintf("${{ needs.%s.outputs.setup-trace-id }}", constants.ActivationJobName) safeOutputsParentSpanID := setupParentSpanNeedsExpr(constants.ActivationJobName) - steps = append(steps, c.generateSetupStep(data, setupActionRef, SetupActionDestination, enableArtifactClient, safeOutputsTraceID, safeOutputsParentSpanID)...) + steps = append(steps, c.generateSetupStep(data, setupActionRef, SetupActionDestination, enableArtifactClient, safeOutputsTraceID, safeOutputsParentSpanID, false)...) } // Mask OTLP telemetry headers immediately after setup so authentication tokens cannot @@ -442,7 +442,7 @@ func (c *Compiler) buildSafeOutputsJobFromParts( // Use the same traceID as the real call so the line count matches exactly countTraceID := fmt.Sprintf("${{ needs.%s.outputs.setup-trace-id }}", constants.ActivationJobName) countParentSpanID := setupParentSpanNeedsExpr(constants.ActivationJobName) - insertIndex += len(c.generateSetupStep(data, setupActionRef, SetupActionDestination, data.SafeOutputs != nil && data.SafeOutputs.UploadArtifact != nil, countTraceID, countParentSpanID)) + insertIndex += len(c.generateSetupStep(data, setupActionRef, SetupActionDestination, data.SafeOutputs != nil && data.SafeOutputs.UploadArtifact != nil, countTraceID, countParentSpanID, false)) } // Add artifact download steps count diff --git a/pkg/workflow/compiler_unlock_job.go b/pkg/workflow/compiler_unlock_job.go index 6c91dbffe72..ee64b366872 100644 --- a/pkg/workflow/compiler_unlock_job.go +++ b/pkg/workflow/compiler_unlock_job.go @@ -40,7 +40,7 @@ func (c *Compiler) buildUnlockJob(data *WorkflowData, threatDetectionEnabled boo // Unlock job depends on activation, reuse its trace ID unlockTraceID := fmt.Sprintf("${{ needs.%s.outputs.setup-trace-id }}", constants.ActivationJobName) unlockParentSpanID := setupParentSpanNeedsExpr(constants.ActivationJobName) - steps = append(steps, c.generateSetupStep(data, setupActionRef, SetupActionDestination, false, unlockTraceID, unlockParentSpanID)...) + steps = append(steps, c.generateSetupStep(data, setupActionRef, SetupActionDestination, false, unlockTraceID, unlockParentSpanID, false)...) // Add unlock step // Build condition: only unlock if issue was locked by activation job diff --git a/pkg/workflow/compiler_yaml_step_generation.go b/pkg/workflow/compiler_yaml_step_generation.go index f99e37de5dc..08fe2bd17ec 100644 --- a/pkg/workflow/compiler_yaml_step_generation.go +++ b/pkg/workflow/compiler_yaml_step_generation.go @@ -116,8 +116,14 @@ func (c *Compiler) generateRestoreActionsSetupStep() string { // - traceID: Optional OTLP trace ID expression for cross-job span correlation (e.g., "${{ needs.activation.outputs.setup-trace-id }}"). Empty string means a new trace ID is generated. // - parentSpanID: Optional OTLP parent span ID expression for setup-span nesting (e.g., setupParentSpanNeedsExpr(constants.ActivationJobName)). Empty string means setup span is emitted as root. // -// For Copilot-engine workflows, the setup step receives INPUT_GH_AW_VERSION, -// which triggers setup.sh to invoke the Copilot CLI toolcache resolver. +// For Copilot-engine workflows, the setup step receives INPUT_INSTALL_COPILOT +// (the resolver gate) and INPUT_GH_AW_VERSION (the compatibility-matrix +// selector) only when installCopilot is true. This is enabled for jobs that +// actually invoke the Copilot CLI (the main agent job and the threat-detection +// job). Other jobs that share the setup action (cache, unlock, safe-outputs, +// activation, pre_activation, publish-assets, repo-memory, notify-comment, +// experiments) leave it disabled so they do not run the resolver +// unnecessarily. // // Returns a slice of strings representing the YAML lines for the setup step. func buildSetupWorkflowRefExpr(data *WorkflowData) string { @@ -131,7 +137,7 @@ func setupParentSpanNeedsExpr(upstreamJob constants.JobName) string { return fmt.Sprintf("${{ needs.%s.outputs.setup-parent-span-id || needs.%s.outputs.setup-span-id }}", upstreamJob, upstreamJob) } -func (c *Compiler) generateSetupStep(data *WorkflowData, setupActionRef string, destination string, enableArtifactClient bool, traceID string, parentSpanID string) []string { +func (c *Compiler) generateSetupStep(data *WorkflowData, setupActionRef string, destination string, enableArtifactClient bool, traceID string, parentSpanID string, installCopilot bool) []string { setupEngineID := "" if data != nil { if data.EngineConfig != nil && data.EngineConfig.ID != "" { @@ -179,8 +185,9 @@ func (c *Compiler) generateSetupStep(data *WorkflowData, setupActionRef string, if enableArtifactClient { lines = append(lines, " INPUT_SAFE_OUTPUT_ARTIFACT_CLIENT: 'true'\n") } - if setupEngineID == "copilot" { + if setupEngineID == "copilot" && installCopilot { lines = append(lines, + " INPUT_INSTALL_COPILOT: 'true'\n", fmt.Sprintf(" INPUT_GH_AW_VERSION: %q\n", GetVersion()), ) } @@ -226,11 +233,17 @@ func (c *Compiler) generateSetupStep(data *WorkflowData, setupActionRef string, if hasWorkflowCallTrigger(data.On) { lines = append(lines, " GH_AW_SETUP_AW_CONTEXT: ${{ inputs.aw_context }}\n") } - if setupEngineID == "copilot" { - // The resolver reads INPUT_GH_AW_VERSION directly from the step env, - // so no action.yml input declaration is required. This keeps the - // action's input surface unchanged. + if setupEngineID == "copilot" && installCopilot { + // INPUT_INSTALL_COPILOT acts as the explicit opt-in for the toolcache + // resolver; setup.sh gates the resolver invocation on this flag. We + // only emit it for jobs that actually run the Copilot CLI (the main + // agent job and the threat-detection job). The resolver also reads + // INPUT_GH_AW_VERSION directly from the step env to pick a compatible + // cached build, so we emit both in the same env block. Neither value + // is declared as an action.yml input — passing them via step env keeps + // the action's input surface unchanged. lines = append(lines, + " INPUT_INSTALL_COPILOT: 'true'\n", fmt.Sprintf(" INPUT_GH_AW_VERSION: %q\n", GetVersion()), ) } diff --git a/pkg/workflow/notify_comment.go b/pkg/workflow/notify_comment.go index 53a069c48be..386ac0ab176 100644 --- a/pkg/workflow/notify_comment.go +++ b/pkg/workflow/notify_comment.go @@ -47,7 +47,7 @@ func (c *Compiler) buildConclusionJob(data *WorkflowData, mainJobName string, sa // Conclusion/notify job depends on activation, reuse its trace ID notifyTraceID := fmt.Sprintf("${{ needs.%s.outputs.setup-trace-id }}", constants.ActivationJobName) notifyParentSpanID := setupParentSpanNeedsExpr(constants.ActivationJobName) - steps = append(steps, c.generateSetupStep(data, setupActionRef, SetupActionDestination, false, notifyTraceID, notifyParentSpanID)...) + steps = append(steps, c.generateSetupStep(data, setupActionRef, SetupActionDestination, false, notifyTraceID, notifyParentSpanID, false)...) } // Add GitHub App token minting step if app is configured diff --git a/pkg/workflow/publish_assets.go b/pkg/workflow/publish_assets.go index e84be99dfd1..0eef16df924 100644 --- a/pkg/workflow/publish_assets.go +++ b/pkg/workflow/publish_assets.go @@ -139,7 +139,7 @@ func (c *Compiler) buildUploadAssetsJob(data *WorkflowData, mainJobName string, // Publish assets job depends on the agent job; reuse its trace ID so all jobs share one OTLP trace publishTraceID := fmt.Sprintf("${{ needs.%s.outputs.setup-trace-id }}", constants.ActivationJobName) publishParentSpanID := setupParentSpanNeedsExpr(constants.ActivationJobName) - preSteps = append(preSteps, c.generateSetupStep(data, setupActionRef, SetupActionDestination, false, publishTraceID, publishParentSpanID)...) + preSteps = append(preSteps, c.generateSetupStep(data, setupActionRef, SetupActionDestination, false, publishTraceID, publishParentSpanID, false)...) } // Step 1: Checkout repository diff --git a/pkg/workflow/repo_memory.go b/pkg/workflow/repo_memory.go index ac04a733e3a..c9a64556366 100644 --- a/pkg/workflow/repo_memory.go +++ b/pkg/workflow/repo_memory.go @@ -625,7 +625,7 @@ func (c *Compiler) buildPushRepoMemoryJob(data *WorkflowData, threatDetectionEna // Repo memory job depends on agent job; reuse the agent's trace ID so all jobs share one OTLP trace repoMemoryTraceID := fmt.Sprintf("${{ needs.%s.outputs.setup-trace-id }}", constants.ActivationJobName) repoMemoryParentSpanID := setupParentSpanNeedsExpr(constants.ActivationJobName) - steps = append(steps, c.generateSetupStep(data, setupActionRef, SetupActionDestination, false, repoMemoryTraceID, repoMemoryParentSpanID)...) + steps = append(steps, c.generateSetupStep(data, setupActionRef, SetupActionDestination, false, repoMemoryTraceID, repoMemoryParentSpanID, false)...) } // Add checkout step to configure git (without checking out files) diff --git a/pkg/workflow/setup_step_version_test.go b/pkg/workflow/setup_step_version_test.go index e139a936c3b..9ee139f4706 100644 --- a/pkg/workflow/setup_step_version_test.go +++ b/pkg/workflow/setup_step_version_test.go @@ -123,7 +123,7 @@ func TestGenerateSetupStepIncludesVersion(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { c := NewCompiler() - lines := c.generateSetupStep(tt.data, "github/gh-aw/actions/setup@abc123", "${{ runner.temp }}/gh-aw", false, "", "") + lines := c.generateSetupStep(tt.data, "github/gh-aw/actions/setup@abc123", "${{ runner.temp }}/gh-aw", false, "", "", false) combined := strings.Join(lines, "") if tt.noVersionLine { @@ -208,7 +208,7 @@ func TestGenerateSetupStepIncludesAWFVersion(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { c := NewCompiler() - lines := c.generateSetupStep(tt.data, "github/gh-aw/actions/setup@abc123", "${{ runner.temp }}/gh-aw", false, "", "") + lines := c.generateSetupStep(tt.data, "github/gh-aw/actions/setup@abc123", "${{ runner.temp }}/gh-aw", false, "", "", false) combined := strings.Join(lines, "") if tt.expectNoAWFLine { @@ -231,7 +231,7 @@ func TestGenerateSetupStepIncludesParentSpanID(t *testing.T) { data := &WorkflowData{Name: "my-workflow"} parentExpr := "${{ needs.activation.outputs.setup-span-id }}" - lines := c.generateSetupStep(data, "github/gh-aw/actions/setup@abc123", "${{ runner.temp }}/gh-aw", false, "", parentExpr) + lines := c.generateSetupStep(data, "github/gh-aw/actions/setup@abc123", "${{ runner.temp }}/gh-aw", false, "", parentExpr, false) combined := strings.Join(lines, "") if !strings.Contains(combined, "parent-span-id: "+parentExpr) { @@ -246,7 +246,7 @@ func TestGenerateSetupStepIncludesEngineID(t *testing.T) { EngineConfig: &EngineConfig{ID: "copilot"}, } - lines := c.generateSetupStep(data, "github/gh-aw/actions/setup@abc123", "${{ runner.temp }}/gh-aw", false, "", "") + lines := c.generateSetupStep(data, "github/gh-aw/actions/setup@abc123", "${{ runner.temp }}/gh-aw", false, "", "", false) combined := strings.Join(lines, "") if !strings.Contains(combined, `GH_AW_INFO_ENGINE_ID: "copilot"`) { @@ -262,10 +262,66 @@ func TestGenerateSetupStepIncludesEngineIDInScriptModeFromAIField(t *testing.T) AI: "claude", } - lines := c.generateSetupStep(data, "github/gh-aw/actions/setup@abc123", "${{ runner.temp }}/gh-aw", false, "", "") + lines := c.generateSetupStep(data, "github/gh-aw/actions/setup@abc123", "${{ runner.temp }}/gh-aw", false, "", "", false) combined := strings.Join(lines, "") if !strings.Contains(combined, `GH_AW_INFO_ENGINE_ID: "claude"`) { t.Fatalf("expected setup script step to include GH_AW_INFO_ENGINE_ID from AI field, got:\n%s", combined) } } + +// TestGenerateSetupStepEmitsInstallCopilotGate verifies the compiler-controlled +// resolver gate: INPUT_INSTALL_COPILOT='true' and INPUT_GH_AW_VERSION must be +// emitted on the setup step env block when (and only when) the workflow uses +// the Copilot engine AND the caller opts in via installCopilot=true. Other +// jobs that share the setup step but do not invoke the Copilot CLI must not +// emit either env var, so the toolcache resolver in setup.sh stays a no-op +// outside the agent and threat-detection jobs. +func TestGenerateSetupStepEmitsInstallCopilotGate(t *testing.T) { + c := NewCompiler() + + copilotData := &WorkflowData{ + Name: "copilot-workflow", + AI: "copilot", + EngineConfig: &EngineConfig{ID: "copilot"}, + } + claudeData := &WorkflowData{ + Name: "claude-workflow", + AI: "claude", + } + + tests := []struct { + name string + data *WorkflowData + installCopilot bool + wantEmit bool + }{ + {name: "copilot engine + opt-in emits both env vars", data: copilotData, installCopilot: true, wantEmit: true}, + {name: "copilot engine without opt-in suppresses env vars", data: copilotData, installCopilot: false, wantEmit: false}, + {name: "non-copilot engine ignores opt-in", data: claudeData, installCopilot: true, wantEmit: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + lines := c.generateSetupStep(tt.data, "github/gh-aw/actions/setup@abc123", "${{ runner.temp }}/gh-aw", false, "", "", tt.installCopilot) + combined := strings.Join(lines, "") + hasGate := strings.Contains(combined, "INPUT_INSTALL_COPILOT: 'true'") + hasVersion := strings.Contains(combined, "INPUT_GH_AW_VERSION:") + if tt.wantEmit { + if !hasGate { + t.Errorf("expected INPUT_INSTALL_COPILOT='true' to be emitted, got:\n%s", combined) + } + if !hasVersion { + t.Errorf("expected INPUT_GH_AW_VERSION to be emitted, got:\n%s", combined) + } + } else { + if hasGate { + t.Errorf("did not expect INPUT_INSTALL_COPILOT to be emitted, got:\n%s", combined) + } + if hasVersion { + t.Errorf("did not expect INPUT_GH_AW_VERSION to be emitted, got:\n%s", combined) + } + } + }) + } +} diff --git a/pkg/workflow/testdata/TestWasmGolden_AllEngines/copilot.golden b/pkg/workflow/testdata/TestWasmGolden_AllEngines/copilot.golden index 5621d4c717f..9741dccc561 100644 --- a/pkg/workflow/testdata/TestWasmGolden_AllEngines/copilot.golden +++ b/pkg/workflow/testdata/TestWasmGolden_AllEngines/copilot.golden @@ -56,7 +56,6 @@ jobs: GH_AW_INFO_VERSION: "1.0.52" GH_AW_INFO_AWF_VERSION: "v0.25.55" GH_AW_INFO_ENGINE_ID: "copilot" - INPUT_GH_AW_VERSION: "dev" - name: Generate agentic run info id: generate_aw_info env: @@ -297,6 +296,7 @@ jobs: GH_AW_INFO_VERSION: "1.0.52" GH_AW_INFO_AWF_VERSION: "v0.25.55" GH_AW_INFO_ENGINE_ID: "copilot" + INPUT_INSTALL_COPILOT: 'true' INPUT_GH_AW_VERSION: "dev" - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -646,7 +646,6 @@ jobs: GH_AW_INFO_VERSION: "1.0.52" GH_AW_INFO_AWF_VERSION: "v0.25.55" GH_AW_INFO_ENGINE_ID: "copilot" - INPUT_GH_AW_VERSION: "dev" - name: Check team membership for workflow id: check_membership uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 diff --git a/pkg/workflow/testdata/TestWasmGolden_CompileFixtures/basic-copilot.golden b/pkg/workflow/testdata/TestWasmGolden_CompileFixtures/basic-copilot.golden index 89109ae36a4..8a91f37ed8d 100644 --- a/pkg/workflow/testdata/TestWasmGolden_CompileFixtures/basic-copilot.golden +++ b/pkg/workflow/testdata/TestWasmGolden_CompileFixtures/basic-copilot.golden @@ -56,7 +56,6 @@ jobs: GH_AW_INFO_VERSION: "1.0.52" GH_AW_INFO_AWF_VERSION: "v0.25.55" GH_AW_INFO_ENGINE_ID: "copilot" - INPUT_GH_AW_VERSION: "dev" - name: Generate agentic run info id: generate_aw_info env: @@ -297,6 +296,7 @@ jobs: GH_AW_INFO_VERSION: "1.0.52" GH_AW_INFO_AWF_VERSION: "v0.25.55" GH_AW_INFO_ENGINE_ID: "copilot" + INPUT_INSTALL_COPILOT: 'true' INPUT_GH_AW_VERSION: "dev" - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -646,7 +646,6 @@ jobs: GH_AW_INFO_VERSION: "1.0.52" GH_AW_INFO_AWF_VERSION: "v0.25.55" GH_AW_INFO_ENGINE_ID: "copilot" - INPUT_GH_AW_VERSION: "dev" - name: Check team membership for workflow id: check_membership uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 diff --git a/pkg/workflow/testdata/TestWasmGolden_CompileFixtures/playwright-cli-mode.golden b/pkg/workflow/testdata/TestWasmGolden_CompileFixtures/playwright-cli-mode.golden index d4c54edacc1..5b637ca627d 100644 --- a/pkg/workflow/testdata/TestWasmGolden_CompileFixtures/playwright-cli-mode.golden +++ b/pkg/workflow/testdata/TestWasmGolden_CompileFixtures/playwright-cli-mode.golden @@ -56,7 +56,6 @@ jobs: GH_AW_INFO_VERSION: "1.0.52" GH_AW_INFO_AWF_VERSION: "v0.25.55" GH_AW_INFO_ENGINE_ID: "copilot" - INPUT_GH_AW_VERSION: "dev" - name: Generate agentic run info id: generate_aw_info env: @@ -307,6 +306,7 @@ jobs: GH_AW_INFO_VERSION: "1.0.52" GH_AW_INFO_AWF_VERSION: "v0.25.55" GH_AW_INFO_ENGINE_ID: "copilot" + INPUT_INSTALL_COPILOT: 'true' INPUT_GH_AW_VERSION: "dev" - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -662,7 +662,6 @@ jobs: GH_AW_INFO_VERSION: "1.0.52" GH_AW_INFO_AWF_VERSION: "v0.25.55" GH_AW_INFO_ENGINE_ID: "copilot" - INPUT_GH_AW_VERSION: "dev" - name: Check team membership for workflow id: check_membership uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 diff --git a/pkg/workflow/testdata/TestWasmGolden_CompileFixtures/smoke-copilot.golden b/pkg/workflow/testdata/TestWasmGolden_CompileFixtures/smoke-copilot.golden index 1e885e27c10..cc98feb6825 100644 --- a/pkg/workflow/testdata/TestWasmGolden_CompileFixtures/smoke-copilot.golden +++ b/pkg/workflow/testdata/TestWasmGolden_CompileFixtures/smoke-copilot.golden @@ -70,7 +70,6 @@ jobs: GH_AW_INFO_VERSION: "1.0.52" GH_AW_INFO_AWF_VERSION: "v0.25.55" GH_AW_INFO_ENGINE_ID: "copilot" - INPUT_GH_AW_VERSION: "dev" - name: Generate agentic run info id: generate_aw_info env: @@ -422,6 +421,7 @@ jobs: GH_AW_INFO_VERSION: "1.0.52" GH_AW_INFO_AWF_VERSION: "v0.25.55" GH_AW_INFO_ENGINE_ID: "copilot" + INPUT_INSTALL_COPILOT: 'true' INPUT_GH_AW_VERSION: "dev" - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -905,7 +905,6 @@ jobs: GH_AW_INFO_VERSION: "1.0.52" GH_AW_INFO_AWF_VERSION: "v0.25.55" GH_AW_INFO_ENGINE_ID: "copilot" - INPUT_GH_AW_VERSION: "dev" - name: Check team membership for workflow id: check_membership uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 diff --git a/pkg/workflow/testdata/TestWasmGolden_CompileFixtures/with-imports.golden b/pkg/workflow/testdata/TestWasmGolden_CompileFixtures/with-imports.golden index 497df36d579..09bb578fb9b 100644 --- a/pkg/workflow/testdata/TestWasmGolden_CompileFixtures/with-imports.golden +++ b/pkg/workflow/testdata/TestWasmGolden_CompileFixtures/with-imports.golden @@ -56,7 +56,6 @@ jobs: GH_AW_INFO_VERSION: "1.0.52" GH_AW_INFO_AWF_VERSION: "v0.25.55" GH_AW_INFO_ENGINE_ID: "copilot" - INPUT_GH_AW_VERSION: "dev" - name: Generate agentic run info id: generate_aw_info env: @@ -298,6 +297,7 @@ jobs: GH_AW_INFO_VERSION: "1.0.52" GH_AW_INFO_AWF_VERSION: "v0.25.55" GH_AW_INFO_ENGINE_ID: "copilot" + INPUT_INSTALL_COPILOT: 'true' INPUT_GH_AW_VERSION: "dev" - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -647,7 +647,6 @@ jobs: GH_AW_INFO_VERSION: "1.0.52" GH_AW_INFO_AWF_VERSION: "v0.25.55" GH_AW_INFO_ENGINE_ID: "copilot" - INPUT_GH_AW_VERSION: "dev" - name: Check team membership for workflow id: check_membership uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 diff --git a/pkg/workflow/threat_detection.go b/pkg/workflow/threat_detection.go index af81cb8824b..e3c0196cabf 100644 --- a/pkg/workflow/threat_detection.go +++ b/pkg/workflow/threat_detection.go @@ -887,7 +887,7 @@ func (c *Compiler) buildDetectionJob(data *WorkflowData) (*Job, error) { // Detection job depends on agent job; reuse the agent's trace ID so all jobs share one OTLP trace detectionTraceID := fmt.Sprintf("${{ needs.%s.outputs.setup-trace-id }}", constants.ActivationJobName) detectionParentSpanID := setupParentSpanNeedsExpr(constants.ActivationJobName) - steps = append(steps, c.generateSetupStep(data, setupActionRef, SetupActionDestination, false, detectionTraceID, detectionParentSpanID)...) + steps = append(steps, c.generateSetupStep(data, setupActionRef, SetupActionDestination, false, detectionTraceID, detectionParentSpanID, true)...) } // Download agent output artifact to access output files (prompt.txt, agent_output.json, patches).