Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions .github/workflows/daily-github-docs-seo-optimizer.lock.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 7 additions & 6 deletions pkg/workflow/copilot_engine_execution.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Comment on lines +149 to +150
case ".rb":
return "ruby", driverName
default:
Expand Down
9 changes: 4 additions & 5 deletions pkg/workflow/copilot_engine_installation.go
Original file line number Diff line number Diff line change
Expand Up @@ -234,19 +234,18 @@ 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 {
case ".py":
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 ""
}
}
Expand Down
57 changes: 52 additions & 5 deletions pkg/workflow/copilot_engine_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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: <outer-node> copilot_harness.cjs <runtime-cmd> <driver> <copilot-binary>
// 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{
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
33 changes: 33 additions & 0 deletions pkg/workflow/runtime_detection.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package workflow

import (
"maps"
"path/filepath"
"strings"

"github.com/github/gh-aw/pkg/constants"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
59 changes: 59 additions & 0 deletions pkg/workflow/runtime_setup_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}