diff --git a/actions/setup/action.yml b/actions/setup/action.yml index d70cc20d41a..131237b9168 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 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.' 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..266835e0e5c --- /dev/null +++ b/actions/setup/js/install_copilot_cli.cjs @@ -0,0 +1,348 @@ +// @ts-check +// install_copilot_cli.cjs — zero-dependency Copilot CLI resolver +// +// 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 +// 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": } + +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) { + core.info(`[install_copilot_cli] ${msg}`); +} + +/** + * @param {string} msg + */ +function logErr(msg) { + core.warning(`[install_copilot_cli] ${msg}`); +} + +/** + * 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.-]+))?$/); + 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). + * @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; + } + 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 "*". + * @param {unknown} row + * @param {ParsedSemver | null} ghAwSemver + * @returns {boolean} + */ +function rowMatchesGhAw(row, ghAwSemver) { + 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); + 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. + * @returns {Promise} + */ +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 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. + * @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 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"). + * @param {unknown} matrix + * @returns {CompatRow[]} + */ +function copilotRows(matrix) { + if (!matrix || typeof matrix !== "object") return []; + const v1 = /** @type {Record} */ matrix["agent-compat-v1"]; + if (!v1 || typeof v1 !== "object") return []; + 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. + * @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; + 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. + * @returns {string} + */ +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). + * @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 (/** @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); + 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. + * @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 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); + 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..1bd150ba18d 100755 --- a/actions/setup/setup.sh +++ b/actions/setup/setup.sh @@ -434,6 +434,26 @@ 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 +# 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:-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/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 06a79c023df..5c645a0ff49 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 ad797c704fb..08fe2bd17ec 100644 --- a/pkg/workflow/compiler_yaml_step_generation.go +++ b/pkg/workflow/compiler_yaml_step_generation.go @@ -116,6 +116,15 @@ 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_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 { if data == nil || data.WorkflowID == "" { @@ -128,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 != "" { @@ -176,11 +185,17 @@ func (c *Compiler) generateSetupStep(data *WorkflowData, setupActionRef string, if enableArtifactClient { lines = append(lines, " INPUT_SAFE_OUTPUT_ARTIFACT_CLIENT: 'true'\n") } + if setupEngineID == "copilot" && 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, engineID=%q", setupActionRef, destination, enableArtifactClient, traceID, parentSpanID, setupEngineID) lines := []string{ " - name: Setup Scripts\n", " id: setup\n", @@ -218,6 +233,20 @@ 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" && 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()), + ) + } return lines } diff --git a/pkg/workflow/copilot_engine_installation.go b/pkg/workflow/copilot_engine_installation.go index 65009db31d1..609f49db77b 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,45 @@ 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") + + // 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) } +// 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..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 b056eb53edc..9741dccc561 100644 --- a/pkg/workflow/testdata/TestWasmGolden_AllEngines/copilot.golden +++ b/pkg/workflow/testdata/TestWasmGolden_AllEngines/copilot.golden @@ -296,6 +296,8 @@ 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 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 diff --git a/pkg/workflow/testdata/TestWasmGolden_CompileFixtures/basic-copilot.golden b/pkg/workflow/testdata/TestWasmGolden_CompileFixtures/basic-copilot.golden index 27eab4e2f17..8a91f37ed8d 100644 --- a/pkg/workflow/testdata/TestWasmGolden_CompileFixtures/basic-copilot.golden +++ b/pkg/workflow/testdata/TestWasmGolden_CompileFixtures/basic-copilot.golden @@ -296,6 +296,8 @@ 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 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 diff --git a/pkg/workflow/testdata/TestWasmGolden_CompileFixtures/playwright-cli-mode.golden b/pkg/workflow/testdata/TestWasmGolden_CompileFixtures/playwright-cli-mode.golden index e8e197e300d..5b637ca627d 100644 --- a/pkg/workflow/testdata/TestWasmGolden_CompileFixtures/playwright-cli-mode.golden +++ b/pkg/workflow/testdata/TestWasmGolden_CompileFixtures/playwright-cli-mode.golden @@ -306,6 +306,8 @@ 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 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 diff --git a/pkg/workflow/testdata/TestWasmGolden_CompileFixtures/smoke-copilot.golden b/pkg/workflow/testdata/TestWasmGolden_CompileFixtures/smoke-copilot.golden index 740b3cb7475..cc98feb6825 100644 --- a/pkg/workflow/testdata/TestWasmGolden_CompileFixtures/smoke-copilot.golden +++ b/pkg/workflow/testdata/TestWasmGolden_CompileFixtures/smoke-copilot.golden @@ -421,6 +421,8 @@ 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 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 diff --git a/pkg/workflow/testdata/TestWasmGolden_CompileFixtures/with-imports.golden b/pkg/workflow/testdata/TestWasmGolden_CompileFixtures/with-imports.golden index 331dedce996..09bb578fb9b 100644 --- a/pkg/workflow/testdata/TestWasmGolden_CompileFixtures/with-imports.golden +++ b/pkg/workflow/testdata/TestWasmGolden_CompileFixtures/with-imports.golden @@ -297,6 +297,8 @@ 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 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 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).