diff --git a/.github/workflows/daily-github-docs-seo-optimizer.lock.yml b/.github/workflows/daily-github-docs-seo-optimizer.lock.yml index a248cbe0db3..44fffb957ba 100644 --- a/.github/workflows/daily-github-docs-seo-optimizer.lock.yml +++ b/.github/workflows/daily-github-docs-seo-optimizer.lock.yml @@ -397,6 +397,11 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false + - name: Setup Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '24' + package-manager-cache: false - name: Create gh-aw temp directory run: bash "${RUNNER_TEMP}/gh-aw/actions/create_gh_aw_tmp_dir.sh" - name: Configure gh CLI for GitHub Enterprise @@ -434,8 +439,8 @@ jobs: GH_HOST: github.com - name: Install AWF binary run: bash "${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh" v0.27.41 --rootless - - name: Install GitHub Copilot SDK (TypeScript) - run: cd "${GITHUB_WORKSPACE}" && npm install --ignore-scripts --no-save @github/copilot-sdk@1.0.8 ts-node typescript + - name: Install GitHub Copilot SDK (Node.js) + run: cd "${GITHUB_WORKSPACE}" && npm install --ignore-scripts --no-save @github/copilot-sdk@1.0.8 - name: Restore agent config folders from base branch if: steps.checkout-pr.outcome == 'success' env: @@ -722,7 +727,7 @@ jobs: fi # shellcheck disable=SC1003,SC2016,SC2086 awf --config "${RUNNER_TEMP}/gh-aw/awf-config.json" --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" ${GH_AW_TOOL_CACHE_MOUNT:+--mount "$GH_AW_TOOL_CACHE_MOUNT"} ${GH_AW_DOCKER_HOST:+--docker-host "$GH_AW_DOCKER_HOST"} --env-all --exclude-env COPILOT_GITHUB_TOKEN --exclude-env MCP_GATEWAY_API_KEY --log-level info --skip-pull \ - -- /bin/bash -c 'set +o histexpand; export PATH="${RUNNER_TEMP}/gh-aw/mcp-cli/bin:$PATH" && : "${RUNNER_TOOL_CACHE:?RUNNER_TOOL_CACHE must be set}"; GH_AW_TOOL_CACHE="$RUNNER_TOOL_CACHE"; export PATH="$(find "$GH_AW_TOOL_CACHE" -maxdepth 5 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true; [ -n "$ERLANG_HOME" ] && export PATH="$ERLANG_HOME/bin:$PATH" || true && GH_AW_NODE_EXEC="${GH_AW_NODE_BIN:-}"; if [ -z "$GH_AW_NODE_EXEC" ] || [ ! -x "$GH_AW_NODE_EXEC" ]; then GH_AW_NODE_EXEC="$(command -v node 2>/dev/null || true)"; fi; if [ -z "$GH_AW_NODE_EXEC" ]; then echo "node runtime missing on this runner — check runtimes.node in workflow YAML" >&2; exit 127; fi; GH_AW_WORKSPACE_NODE_MODULES="${GITHUB_WORKSPACE:-$PWD}/node_modules"; if [ -d "$GH_AW_WORKSPACE_NODE_MODULES" ]; then export NODE_PATH="${GH_AW_WORKSPACE_NODE_MODULES}${NODE_PATH:+:${NODE_PATH}}"; fi; GH_AW_NPM_GLOBAL_ROOT="$(npm root -g 2>/dev/null || true)"; if [ -n "$GH_AW_NPM_GLOBAL_ROOT" ]; then export NODE_PATH="${GH_AW_NPM_GLOBAL_ROOT}${NODE_PATH:+:${NODE_PATH}}"; fi; "$GH_AW_NODE_EXEC" ${RUNNER_TEMP}/gh-aw/actions/copilot_harness.cjs ts-node "${GITHUB_WORKSPACE}/.github/drivers/daily_github_docs_seo_optimizer_driver.ts" /usr/local/bin/copilot' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log + -- /bin/bash -c 'set +o histexpand; export PATH="${RUNNER_TEMP}/gh-aw/mcp-cli/bin:$PATH" && : "${RUNNER_TOOL_CACHE:?RUNNER_TOOL_CACHE must be set}"; GH_AW_TOOL_CACHE="$RUNNER_TOOL_CACHE"; export PATH="$(find "$GH_AW_TOOL_CACHE" -maxdepth 5 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true; [ -n "$ERLANG_HOME" ] && export PATH="$ERLANG_HOME/bin:$PATH" || true && GH_AW_NODE_EXEC="${GH_AW_NODE_BIN:-}"; if [ -z "$GH_AW_NODE_EXEC" ] || [ ! -x "$GH_AW_NODE_EXEC" ]; then GH_AW_NODE_EXEC="$(command -v node 2>/dev/null || true)"; fi; if [ -z "$GH_AW_NODE_EXEC" ]; then echo "node runtime missing on this runner — check runtimes.node in workflow YAML" >&2; exit 127; fi; GH_AW_WORKSPACE_NODE_MODULES="${GITHUB_WORKSPACE:-$PWD}/node_modules"; if [ -d "$GH_AW_WORKSPACE_NODE_MODULES" ]; then export NODE_PATH="${GH_AW_WORKSPACE_NODE_MODULES}${NODE_PATH:+:${NODE_PATH}}"; fi; GH_AW_NPM_GLOBAL_ROOT="$(npm root -g 2>/dev/null || true)"; if [ -n "$GH_AW_NPM_GLOBAL_ROOT" ]; then export NODE_PATH="${GH_AW_NPM_GLOBAL_ROOT}${NODE_PATH:+:${NODE_PATH}}"; fi; "$GH_AW_NODE_EXEC" ${RUNNER_TEMP}/gh-aw/actions/copilot_harness.cjs "$GH_AW_NODE_EXEC" "${GITHUB_WORKSPACE}/.github/drivers/daily_github_docs_seo_optimizer_driver.ts" /usr/local/bin/copilot' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log env: AWF_REFLECT_ENABLED: 1 COPILOT_AGENT_RUNNER_TYPE: STANDALONE diff --git a/pkg/workflow/copilot_engine_execution.go b/pkg/workflow/copilot_engine_execution.go index 5183114b359..cf5ab0ccb44 100644 --- a/pkg/workflow/copilot_engine_execution.go +++ b/pkg/workflow/copilot_engine_execution.go @@ -133,11 +133,11 @@ const copilotSDKPythonPathExpression = "${{ github.workspace }}/.gh-aw/copilot-s // SetupActionDestinationShell). For bare command names (no extension), the driver is treated // as an arbitrary executable in PATH: runtimeCmd is the command itself and driverArg is empty. // -// - .js/.cjs/.mjs → ("$GH_AW_NODE_EXEC", "driver.cjs") -// - .py → ("python3", "driver.py") -// - .ts/.mts → ("ts-node", "driver.ts") -// - .rb → ("ruby", "driver.rb") -// - (no ext) → ("my-driver", "") +// - .js/.cjs/.mjs → ("$GH_AW_NODE_EXEC", "driver.cjs") +// - .py → ("python3", "driver.py") +// - .ts/.mts → ("$GH_AW_NODE_EXEC", "driver.ts") +// - .rb → ("ruby", "driver.rb") +// - (no ext) → ("my-driver", "") func copilotSDKDriverExecArgs(driverName string) (runtimeCmd, driverArg string) { ext := strings.ToLower(filepath.Ext(driverName)) switch ext { @@ -146,7 +146,8 @@ func copilotSDKDriverExecArgs(driverName string) (runtimeCmd, driverArg string) case ".py": return "python3", driverName case ".ts", ".mts": - return "ts-node", driverName + // Node 24 runs TypeScript natively; use the same node executor as .js drivers. + return `"$GH_AW_NODE_EXEC"`, driverName case ".rb": return "ruby", driverName default: diff --git a/pkg/workflow/copilot_engine_installation.go b/pkg/workflow/copilot_engine_installation.go index 6619c5d6d54..aa8a9eb3077 100644 --- a/pkg/workflow/copilot_engine_installation.go +++ b/pkg/workflow/copilot_engine_installation.go @@ -234,8 +234,9 @@ func specToInstallStep(spec copilotSDKInstallSpec) GitHubActionStep { // sdkDriverInstallCommand returns a synthetic command string for the given driver filename // that can be passed to getCopilotSDKInstallSpec/detectRuntimeFromCopilotCommand to select -// the correct SDK package manager. Python, Ruby, and TypeScript extensions need special -// handling; JS drivers and arbitrary commands (no extension) fall back to the Node.js default. +// the correct SDK package manager. Python and Ruby extensions need special handling; +// JS, TypeScript, and arbitrary commands (no extension) fall back to the Node.js default. +// TypeScript uses Node.js native support (Node 24+) so no extra toolchain install is needed. func sdkDriverInstallCommand(driverName string) string { ext := strings.ToLower(filepath.Ext(driverName)) switch ext { @@ -243,10 +244,8 @@ func sdkDriverInstallCommand(driverName string) string { return "python3 " + driverName case ".rb": return "ruby " + driverName - case ".ts", ".mts": - return "ts-node " + driverName default: - // .js/.cjs/.mjs and no-extension (arbitrary commands) default to Node.js. + // .js/.cjs/.mjs, .ts/.mts, and no-extension (arbitrary commands) default to Node.js. return "" } } diff --git a/pkg/workflow/copilot_engine_test.go b/pkg/workflow/copilot_engine_test.go index 96460a3a801..56e8ecbffb4 100644 --- a/pkg/workflow/copilot_engine_test.go +++ b/pkg/workflow/copilot_engine_test.go @@ -545,14 +545,54 @@ func TestCopilotEngineExecutionStepsWithCopilotSDKTypeScriptDriver(t *testing.T) } stepContent := strings.Join([]string(steps[0]), "\n") - if !strings.Contains(stepContent, "ts-node") { - t.Fatalf("Expected TypeScript SDK driver mode to use ts-node runtime, got:\n%s", stepContent) + // The harness is invoked as: copilot_harness.cjs + // Verify the runtime argument passed to the harness is GH_AW_NODE_EXEC (native Node, not ts-node). + if !strings.Contains(stepContent, `copilot_harness.cjs "$GH_AW_NODE_EXEC"`) { + t.Fatalf("Expected TypeScript SDK driver to pass GH_AW_NODE_EXEC as runtime to harness, got:\n%s", stepContent) + } + if strings.Contains(stepContent, "ts-node") { + t.Fatalf("Expected TypeScript SDK driver to NOT use ts-node (Node 24 runs TS natively), got:\n%s", stepContent) } if !strings.Contains(stepContent, "my_driver.ts") { t.Fatalf("Expected SDK driver mode to include my_driver.ts, got:\n%s", stepContent) } } +// TestCopilotSDKDriverExecArgs directly verifies the runtime command returned for each +// driver file extension, ensuring TypeScript uses native Node.js (not ts-node). +func TestCopilotSDKDriverExecArgs(t *testing.T) { + tests := []struct { + driver string + wantRuntime string + wantDriverArg string + wantNotRuntime string + }{ + {driver: "agent.js", wantRuntime: `"$GH_AW_NODE_EXEC"`, wantDriverArg: "agent.js"}, + {driver: "agent.cjs", wantRuntime: `"$GH_AW_NODE_EXEC"`, wantDriverArg: "agent.cjs"}, + {driver: "agent.mjs", wantRuntime: `"$GH_AW_NODE_EXEC"`, wantDriverArg: "agent.mjs"}, + {driver: "agent.ts", wantRuntime: `"$GH_AW_NODE_EXEC"`, wantDriverArg: "agent.ts", wantNotRuntime: "ts-node"}, + {driver: "agent.mts", wantRuntime: `"$GH_AW_NODE_EXEC"`, wantDriverArg: "agent.mts", wantNotRuntime: "ts-node"}, + {driver: "agent.py", wantRuntime: "python3", wantDriverArg: "agent.py"}, + {driver: "agent.rb", wantRuntime: "ruby", wantDriverArg: "agent.rb"}, + {driver: "my-driver", wantRuntime: "my-driver", wantDriverArg: ""}, + } + + for _, tt := range tests { + t.Run(tt.driver, func(t *testing.T) { + runtime, driverArg := copilotSDKDriverExecArgs(tt.driver) + if runtime != tt.wantRuntime { + t.Errorf("copilotSDKDriverExecArgs(%q) runtime = %q, want %q", tt.driver, runtime, tt.wantRuntime) + } + if driverArg != tt.wantDriverArg { + t.Errorf("copilotSDKDriverExecArgs(%q) driverArg = %q, want %q", tt.driver, driverArg, tt.wantDriverArg) + } + if tt.wantNotRuntime != "" && runtime == tt.wantNotRuntime { + t.Errorf("copilotSDKDriverExecArgs(%q) runtime = %q, must NOT be %q", tt.driver, runtime, tt.wantNotRuntime) + } + }) + } +} + func TestCopilotEngineExecutionStepsWithCopilotSDKRubyDriver(t *testing.T) { engine := NewCopilotEngine() workflowData := &WorkflowData{ @@ -2236,6 +2276,13 @@ func TestCopilotEngineInstallationWithCommandAndCopilotSDK(t *testing.T) { expectedRun: "npm install --ignore-scripts --no-save @github/copilot-sdk@" + string(constants.DefaultCopilotSDKVersion), expectedSteps: 1, }, + { + name: "ts-node command installs ts-node and typescript alongside sdk", + command: "ts-node driver.ts", + expectedName: "name: Install GitHub Copilot SDK (TypeScript)", + expectedRun: "npm install --ignore-scripts --no-save @github/copilot-sdk@" + string(constants.DefaultCopilotSDKVersion) + " ts-node typescript", + expectedSteps: 1, + }, { name: "env wrapper command is detected", command: "env FOO=bar python script.py", @@ -2314,10 +2361,10 @@ func TestCopilotEngineInstallationWithCopilotSDKDriver(t *testing.T) { expectedRun: "python3 -m pip install --disable-pip-version-check --target \"${GITHUB_WORKSPACE}/.gh-aw/copilot-sdk/python\" github-copilot-sdk==" + string(constants.DefaultCopilotSDKVersion), }, { - name: "typescript driver installs ts-node toolchain and sdk", + name: "typescript driver uses node sdk install (node 24 native ts support)", driver: "my_driver.ts", - expectedName: "name: Install GitHub Copilot SDK (TypeScript)", - expectedRun: "npm install --ignore-scripts --no-save @github/copilot-sdk@" + string(constants.DefaultCopilotSDKVersion) + " ts-node typescript", + expectedName: "name: Install GitHub Copilot SDK (Node.js)", + expectedRun: "npm install --ignore-scripts --no-save @github/copilot-sdk@" + string(constants.DefaultCopilotSDKVersion), }, { name: "ruby driver uses npm sdk install fallback", diff --git a/pkg/workflow/runtime_detection.go b/pkg/workflow/runtime_detection.go index 2a8c11c41ca..01999205d87 100644 --- a/pkg/workflow/runtime_detection.go +++ b/pkg/workflow/runtime_detection.go @@ -2,6 +2,7 @@ package workflow import ( "maps" + "path/filepath" "strings" "github.com/github/gh-aw/pkg/constants" @@ -84,6 +85,17 @@ func DetectRuntimeRequirements(workflowData *WorkflowData) []RuntimeRequirement } } + // When using a TypeScript Copilot SDK driver (.ts/.mts extension), require Node 24. + // Node 24 runs TypeScript natively; earlier versions do not support this. + // This only applies to file-extension-based driver detection, not to engine.command + // configurations (e.g. "ts-node driver.ts") which manage their own toolchain. + if requiresNode24ForTypeScriptSDKDriver(workflowData) { + nodeRuntime := findRuntimeByID("node") + if nodeRuntime != nil { + updateRequiredRuntime(nodeRuntime, string(constants.DefaultNodeVersion), requirements) + } + } + // Detect runtimes required by LSP server configurations. // Each known LSP server declares the runtime it needs (e.g. "go" for gopls, // "ruby" for solargraph). Feeding these through the runtime manager ensures @@ -150,6 +162,27 @@ func requiresNodeForEngineHarness(workflowData *WorkflowData) bool { return strings.EqualFold(engineID, string(constants.CopilotEngine)) } +// requiresNode24ForTypeScriptSDKDriver returns true when a Copilot SDK driver is a TypeScript +// file (.ts or .mts) detected by extension. Node 24 runs TypeScript natively; the runtime +// setup ensures the correct version is provisioned. +// +// This does not apply when engine.command is set (e.g., "ts-node driver.ts"), since those +// configurations manage their own TypeScript toolchain independently. +func requiresNode24ForTypeScriptSDKDriver(workflowData *WorkflowData) bool { + if workflowData == nil || workflowData.EngineConfig == nil { + return false + } + if !workflowData.EngineConfig.CopilotSDK { + return false + } + // engine.command takes precedence; the user manages the toolchain explicitly. + if workflowData.EngineConfig.Command != "" { + return false + } + ext := filepath.Ext(workflowData.EngineConfig.Driver) + return strings.EqualFold(ext, ".ts") || strings.EqualFold(ext, ".mts") +} + func detectFromInlineEngineDriver(workflowData *WorkflowData, requirements map[string]*RuntimeRequirement) { if workflowData == nil || workflowData.EngineConfig == nil || workflowData.EngineConfig.InlineDriver == nil { return diff --git a/pkg/workflow/runtime_setup_test.go b/pkg/workflow/runtime_setup_test.go index 34016975c28..9771396e9b6 100644 --- a/pkg/workflow/runtime_setup_test.go +++ b/pkg/workflow/runtime_setup_test.go @@ -1251,3 +1251,62 @@ func TestDetectRuntimeRequirements_CustomDriverDoesNotAddNodeForNonCopilotEngine } } } + +// TestDetectRuntimeRequirements_TypeScriptSDKDriverAddsNode24 verifies that a Copilot SDK +// workflow with a .ts/.mts driver requires Node 24 for native TypeScript execution. +func TestDetectRuntimeRequirements_TypeScriptSDKDriverAddsNode24(t *testing.T) { + tests := []struct { + name string + driver string + }{ + {name: ".ts driver", driver: "my_driver.ts"}, + {name: ".mts driver", driver: "my_driver.mts"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + data := &WorkflowData{ + RunsOn: "runs-on: ubuntu-latest", + EngineConfig: &EngineConfig{ + CopilotSDK: true, + Driver: tt.driver, + }, + } + + requirements := DetectRuntimeRequirements(data) + + var nodeReq *RuntimeRequirement + for i := range requirements { + if requirements[i].Runtime != nil && requirements[i].Runtime.ID == "node" { + nodeReq = &requirements[i] + break + } + } + + require.NotNil(t, nodeReq, "Expected Node.js 24 runtime requirement for TypeScript SDK driver %q", tt.driver) + assert.Equal(t, string(constants.DefaultNodeVersion), nodeReq.Version, + "TypeScript SDK driver %q should require Node.js %s for native TypeScript support", tt.driver, constants.DefaultNodeVersion) + }) + } +} + +// TestDetectRuntimeRequirements_TypeScriptSDKCommandDoesNotAddNode24 verifies that when +// engine.command is set (e.g., ts-node driver.ts), no automatic Node 24 requirement is added, +// since the user manages the toolchain via engine.command. +func TestDetectRuntimeRequirements_TypeScriptSDKCommandDoesNotAddNode24(t *testing.T) { + data := &WorkflowData{ + RunsOn: "runs-on: ubuntu-latest", + EngineConfig: &EngineConfig{ + CopilotSDK: true, + Command: "ts-node driver.ts", + }, + } + + requirements := DetectRuntimeRequirements(data) + + for _, req := range requirements { + if req.Runtime != nil && req.Runtime.ID == "node" && req.Version == string(constants.DefaultNodeVersion) { + t.Fatalf("Expected no explicit Node 24 requirement when engine.command is set (ts-node manages its own toolchain), got Node version %q", req.Version) + } + } +}