diff --git a/src/Misc/externals.sh b/src/Misc/externals.sh index 397533db89d..d616ab71783 100755 --- a/src/Misc/externals.sh +++ b/src/Misc/externals.sh @@ -9,6 +9,9 @@ NODE_ALPINE_URL=https://github.com/actions/alpine_nodejs/releases/download NODE20_VERSION="20.19.5" NODE24_VERSION="24.11.1" +BUN_URL=https://github.com/oven-sh/bun/releases/download +BUN_VERSION="1.3.2" + get_abs_path() { # exploits the fact that pwd will print abs path when no args echo "$(cd "$(dirname "$1")" && pwd)/$(basename "$1")" @@ -142,18 +145,26 @@ if [[ "$PACKAGERUNTIME" == "win-x64" || "$PACKAGERUNTIME" == "win-x86" ]]; then acquireExternalTool "$NODE_URL/v${NODE20_VERSION}/$PACKAGERUNTIME/node.lib" node20/bin acquireExternalTool "$NODE_URL/v${NODE24_VERSION}/$PACKAGERUNTIME/node.exe" node24/bin acquireExternalTool "$NODE_URL/v${NODE24_VERSION}/$PACKAGERUNTIME/node.lib" node24/bin + + # Note: Bun is only available for Windows x64, not for win-x86 (32-bit Windows) + if [[ "$PACKAGERUNTIME" == "win-x64" ]]; then + acquireExternalTool "$BUN_URL/bun-v${BUN_VERSION}/bun-windows-x64.zip" bun/bin fix_nested_dir + fi + if [[ "$PRECACHE" != "" ]]; then acquireExternalTool "https://github.com/microsoft/vswhere/releases/download/2.6.7/vswhere.exe" vswhere fi fi -# Download the external tools only for Windows. +# Download the external tools only for Windows ARM64. +# Note: Bun doesn't have official Windows ARM64 release yet, so we skip it for now. if [[ "$PACKAGERUNTIME" == "win-arm64" ]]; then # todo: replace these with official release when available acquireExternalTool "$NODE_URL/v${NODE20_VERSION}/$PACKAGERUNTIME/node.exe" node20/bin acquireExternalTool "$NODE_URL/v${NODE20_VERSION}/$PACKAGERUNTIME/node.lib" node20/bin acquireExternalTool "$NODE_URL/v${NODE24_VERSION}/$PACKAGERUNTIME/node.exe" node24/bin acquireExternalTool "$NODE_URL/v${NODE24_VERSION}/$PACKAGERUNTIME/node.lib" node24/bin + if [[ "$PRECACHE" != "" ]]; then acquireExternalTool "https://github.com/microsoft/vswhere/releases/download/2.6.7/vswhere.exe" vswhere fi @@ -163,12 +174,14 @@ fi if [[ "$PACKAGERUNTIME" == "osx-x64" ]]; then acquireExternalTool "$NODE_URL/v${NODE20_VERSION}/node-v${NODE20_VERSION}-darwin-x64.tar.gz" node20 fix_nested_dir acquireExternalTool "$NODE_URL/v${NODE24_VERSION}/node-v${NODE24_VERSION}-darwin-x64.tar.gz" node24 fix_nested_dir + acquireExternalTool "$BUN_URL/bun-v${BUN_VERSION}/bun-darwin-x64.zip" bun/bin fix_nested_dir fi if [[ "$PACKAGERUNTIME" == "osx-arm64" ]]; then # node.js v12 doesn't support macOS on arm64. acquireExternalTool "$NODE_URL/v${NODE20_VERSION}/node-v${NODE20_VERSION}-darwin-arm64.tar.gz" node20 fix_nested_dir acquireExternalTool "$NODE_URL/v${NODE24_VERSION}/node-v${NODE24_VERSION}-darwin-arm64.tar.gz" node24 fix_nested_dir + acquireExternalTool "$BUN_URL/bun-v${BUN_VERSION}/bun-darwin-aarch64.zip" bun/bin fix_nested_dir fi # Download the external tools for Linux PACKAGERUNTIMEs. @@ -177,11 +190,15 @@ if [[ "$PACKAGERUNTIME" == "linux-x64" ]]; then acquireExternalTool "$NODE_ALPINE_URL/v${NODE20_VERSION}/node-v${NODE20_VERSION}-alpine-x64.tar.gz" node20_alpine acquireExternalTool "$NODE_URL/v${NODE24_VERSION}/node-v${NODE24_VERSION}-linux-x64.tar.gz" node24 fix_nested_dir acquireExternalTool "$NODE_ALPINE_URL/v${NODE24_VERSION}/node-v${NODE24_VERSION}-alpine-x64.tar.gz" node24_alpine + acquireExternalTool "$BUN_URL/bun-v${BUN_VERSION}/bun-linux-x64.zip" bun/bin fix_nested_dir + acquireExternalTool "$BUN_URL/bun-v${BUN_VERSION}/bun-linux-x64-musl.zip" bun_alpine/bin fix_nested_dir fi if [[ "$PACKAGERUNTIME" == "linux-arm64" ]]; then acquireExternalTool "$NODE_URL/v${NODE20_VERSION}/node-v${NODE20_VERSION}-linux-arm64.tar.gz" node20 fix_nested_dir acquireExternalTool "$NODE_URL/v${NODE24_VERSION}/node-v${NODE24_VERSION}-linux-arm64.tar.gz" node24 fix_nested_dir + acquireExternalTool "$BUN_URL/bun-v${BUN_VERSION}/bun-linux-aarch64.zip" bun/bin fix_nested_dir + acquireExternalTool "$BUN_URL/bun-v${BUN_VERSION}/bun-linux-aarch64-musl.zip" bun_alpine/bin fix_nested_dir fi if [[ "$PACKAGERUNTIME" == "linux-arm" ]]; then diff --git a/src/Runner.Common/Constants.cs b/src/Runner.Common/Constants.cs index d3bce0a0656..b4e8997cf09 100644 --- a/src/Runner.Common/Constants.cs +++ b/src/Runner.Common/Constants.cs @@ -173,19 +173,20 @@ public static class Features public static readonly string SnapshotPreflightHostedRunnerCheck = "actions_snapshot_preflight_hosted_runner_check"; public static readonly string SnapshotPreflightImageGenPoolCheck = "actions_snapshot_preflight_image_gen_pool_check"; public static readonly string CompareWorkflowParser = "actions_runner_compare_workflow_parser"; + public static readonly string AllowBunRuntime = "actions.runner.allowbunruntime"; } - + // Node version migration related constants public static class NodeMigration { // Node versions public static readonly string Node20 = "node20"; public static readonly string Node24 = "node24"; - + // Environment variables for controlling node version selection public static readonly string ForceNode24Variable = "FORCE_JAVASCRIPT_ACTIONS_TO_NODE24"; public static readonly string AllowUnsecureNodeVersionVariable = "ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION"; - + // Feature flags for controlling the migration phases public static readonly string UseNode24ByDefaultFlag = "actions.runner.usenode24bydefault"; public static readonly string RequireNode24Flag = "actions.runner.requirenode24"; diff --git a/src/Runner.Worker/ActionManifestManager.cs b/src/Runner.Worker/ActionManifestManager.cs index 014c053aa59..b54e8f06d08 100644 --- a/src/Runner.Worker/ActionManifestManager.cs +++ b/src/Runner.Worker/ActionManifestManager.cs @@ -56,7 +56,7 @@ public ActionDefinitionDataNew Load(IExecutionContext executionContext, string m ActionDefinitionDataNew actionDefinition = new(); // Clean up file name real quick - // Instead of using Regex which can be computationally expensive, + // Instead of using Regex which can be computationally expensive, // we can just remove the # of characters from the fileName according to the length of the basePath string basePath = HostContext.GetDirectory(WellKnownDirectory.Actions); string fileRelativePath = manifestFile; @@ -464,7 +464,8 @@ private ActionExecutionData ConvertRuns( else if (string.Equals(usingToken.Value, "node12", StringComparison.OrdinalIgnoreCase) || string.Equals(usingToken.Value, "node16", StringComparison.OrdinalIgnoreCase) || string.Equals(usingToken.Value, "node20", StringComparison.OrdinalIgnoreCase) || - string.Equals(usingToken.Value, "node24", StringComparison.OrdinalIgnoreCase)) + string.Equals(usingToken.Value, "node24", StringComparison.OrdinalIgnoreCase) || + string.Equals(usingToken.Value, "bun", StringComparison.OrdinalIgnoreCase)) { if (string.IsNullOrEmpty(mainToken?.Value)) { @@ -504,7 +505,7 @@ private ActionExecutionData ConvertRuns( } else { - throw new ArgumentOutOfRangeException($"'using: {usingToken.Value}' is not supported, use 'docker', 'node12', 'node16', 'node20' or 'node24' instead."); + throw new ArgumentOutOfRangeException($"'using: {usingToken.Value}' is not supported, use 'docker', 'node12', 'node16', 'node20', 'node24' or 'bun' instead."); } } else if (pluginToken != null) @@ -515,7 +516,7 @@ private ActionExecutionData ConvertRuns( }; } - throw new NotSupportedException("Missing 'using' value. 'using' requires 'composite', 'docker', 'node12', 'node16', 'node20' or 'node24'."); + throw new NotSupportedException("Missing 'using' value. 'using' requires 'composite', 'docker', 'node12', 'node16', 'node20', 'node24' or 'bun'."); } private void ConvertInputs( @@ -600,4 +601,3 @@ public sealed class CompositeActionExecutionDataNew : ActionExecutionData public MappingToken Outputs { get; set; } } } - diff --git a/src/Runner.Worker/ActionManifestManagerLegacy.cs b/src/Runner.Worker/ActionManifestManagerLegacy.cs index 89d9ae8b56d..701f6f93e27 100644 --- a/src/Runner.Worker/ActionManifestManagerLegacy.cs +++ b/src/Runner.Worker/ActionManifestManagerLegacy.cs @@ -56,7 +56,7 @@ public ActionDefinitionData Load(IExecutionContext executionContext, string mani ActionDefinitionData actionDefinition = new(); // Clean up file name real quick - // Instead of using Regex which can be computationally expensive, + // Instead of using Regex which can be computationally expensive, // we can just remove the # of characters from the fileName according to the length of the basePath string basePath = HostContext.GetDirectory(WellKnownDirectory.Actions); string fileRelativePath = manifestFile; @@ -451,7 +451,8 @@ private ActionExecutionData ConvertRuns( else if (string.Equals(usingToken.Value, "node12", StringComparison.OrdinalIgnoreCase) || string.Equals(usingToken.Value, "node16", StringComparison.OrdinalIgnoreCase) || string.Equals(usingToken.Value, "node20", StringComparison.OrdinalIgnoreCase) || - string.Equals(usingToken.Value, "node24", StringComparison.OrdinalIgnoreCase)) + string.Equals(usingToken.Value, "node24", StringComparison.OrdinalIgnoreCase) || + string.Equals(usingToken.Value, "bun", StringComparison.OrdinalIgnoreCase)) { if (string.IsNullOrEmpty(mainToken?.Value)) { @@ -491,7 +492,7 @@ private ActionExecutionData ConvertRuns( } else { - throw new ArgumentOutOfRangeException($"'using: {usingToken.Value}' is not supported, use 'docker', 'node12', 'node16', 'node20' or 'node24' instead."); + throw new ArgumentOutOfRangeException($"'using: {usingToken.Value}' is not supported, use 'docker', 'node12', 'node16', 'node20', 'node24' or 'bun' instead."); } } else if (pluginToken != null) @@ -502,7 +503,7 @@ private ActionExecutionData ConvertRuns( }; } - throw new NotSupportedException("Missing 'using' value. 'using' requires 'composite', 'docker', 'node12', 'node16', 'node20' or 'node24'."); + throw new NotSupportedException("Missing 'using' value. 'using' requires 'composite', 'docker', 'node12', 'node16', 'node20', 'node24' or 'bun'."); } private void ConvertInputs( @@ -543,4 +544,3 @@ private void ConvertInputs( } } } - diff --git a/src/Runner.Worker/FeatureManager.cs b/src/Runner.Worker/FeatureManager.cs index 499995d1384..835f6ba0c7b 100644 --- a/src/Runner.Worker/FeatureManager.cs +++ b/src/Runner.Worker/FeatureManager.cs @@ -16,5 +16,10 @@ public static bool IsContainerActionRunnerTempEnabled(Variables variables) { return variables?.GetBoolean(Constants.Runner.Features.ContainerActionRunnerTemp) ?? false; } + + public static bool IsBunRuntimeEnabled(Variables variables) + { + return variables?.GetBoolean(Constants.Runner.Features.AllowBunRuntime) ?? false; + } } } diff --git a/src/Runner.Worker/Handlers/NodeScriptActionHandler.cs b/src/Runner.Worker/Handlers/NodeScriptActionHandler.cs index a399f13d1d2..d765f250108 100644 --- a/src/Runner.Worker/Handlers/NodeScriptActionHandler.cs +++ b/src/Runner.Worker/Handlers/NodeScriptActionHandler.cs @@ -110,11 +110,28 @@ public async Task RunAsync(ActionRunStage stage) workingDirectory = HostContext.GetDirectory(WellKnownDirectory.Work); } - var nodeRuntimeVersion = await StepHost.DetermineNodeRuntimeVersion(ExecutionContext, Data.NodeVersion); - ExecutionContext.StepTelemetry.Type = nodeRuntimeVersion; - string file = Path.Combine(HostContext.GetDirectory(WellKnownDirectory.Externals), nodeRuntimeVersion, "bin", $"node{IOUtil.ExeExtension}"); + string file; + bool isBun = string.Equals(Data.NodeVersion, "bun", StringComparison.OrdinalIgnoreCase); - // Format the arguments passed to node. + if (isBun) + { + if (!FeatureManager.IsBunRuntimeEnabled(ExecutionContext.Global.Variables)) + { + throw new NotSupportedException($"Bun runtime is not enabled. Please enable the feature flag '{Constants.Runner.Features.AllowBunRuntime}' to use Bun runtime."); + } + + var bunRuntimeVersion = await StepHost.DetermineBunRuntimeVersion(ExecutionContext); + ExecutionContext.StepTelemetry.Type = bunRuntimeVersion; + file = Path.Combine(HostContext.GetDirectory(WellKnownDirectory.Externals), bunRuntimeVersion, "bin", $"bun{IOUtil.ExeExtension}"); + } + else + { + var nodeRuntimeVersion = await StepHost.DetermineNodeRuntimeVersion(ExecutionContext, Data.NodeVersion); + ExecutionContext.StepTelemetry.Type = nodeRuntimeVersion; + file = Path.Combine(HostContext.GetDirectory(WellKnownDirectory.Externals), nodeRuntimeVersion, "bin", $"node{IOUtil.ExeExtension}"); + } + + // Format the arguments passed to node/bun. // 1) Wrap the script file path in double quotes. // 2) Escape double quotes within the script file path. Double-quote is a valid // file name character on Linux. @@ -128,7 +145,8 @@ public async Task RunAsync(ActionRunStage stage) Encoding outputEncoding = null; #endif - // Remove environment variable that may cause conflicts with the node within the runner. + // Remove environment variable that may cause conflicts with the node/bun within the runner. + // This applies to both Node.js and Bun to avoid conflicts with the runner's environment. Environment.Remove("NODE_ICU_DATA"); // https://github.com/actions/runner/issues/795 using (var stdoutManager = new OutputManager(ExecutionContext, ActionCommandManager)) @@ -162,7 +180,8 @@ public async Task RunAsync(ActionRunStage stage) else { var exitCode = await step; - ExecutionContext.Debug($"Node Action run completed with exit code {exitCode}"); + string runtimeName = isBun ? "Bun" : "Node"; + ExecutionContext.Debug($"{runtimeName} Action run completed with exit code {exitCode}"); if (exitCode != 0) { ExecutionContext.Result = TaskResult.Failed; diff --git a/src/Runner.Worker/Handlers/StepHost.cs b/src/Runner.Worker/Handlers/StepHost.cs index 211009658e4..cec8a716bcf 100644 --- a/src/Runner.Worker/Handlers/StepHost.cs +++ b/src/Runner.Worker/Handlers/StepHost.cs @@ -21,6 +21,8 @@ public interface IStepHost : IRunnerService Task DetermineNodeRuntimeVersion(IExecutionContext executionContext, string preferredVersion); + Task DetermineBunRuntimeVersion(IExecutionContext executionContext); + Task ExecuteAsync(IExecutionContext context, string workingDirectory, string fileName, @@ -64,10 +66,31 @@ public Task DetermineNodeRuntimeVersion(IExecutionContext executionConte { executionContext.Warning(warningMessage); } - + return Task.FromResult(nodeVersion); } + public Task DetermineBunRuntimeVersion(IExecutionContext executionContext) + { + // Check platform compatibility for Bun runtime + if (Constants.Runner.PlatformArchitecture.Equals(Constants.Architecture.X86)) + { + var os = Constants.Runner.Platform.ToString(); + var msg = $"Bun runtime is not supported on {os} x86 (32-bit) platforms."; + throw new NotSupportedException(msg); + } + + if (Constants.Runner.PlatformArchitecture.Equals(Constants.Architecture.Arm) && + Constants.Runner.Platform.Equals(Constants.OSPlatform.Linux)) + { + var msg = "Bun runtime is not supported on Linux ARM32 platforms."; + throw new NotSupportedException(msg); + } + + // Bun runtime version is simply "bun" + return Task.FromResult("bun"); + } + public async Task ExecuteAsync(IExecutionContext context, string workingDirectory, string fileName, @@ -183,6 +206,59 @@ public async Task DetermineNodeRuntimeVersion(IExecutionContext executio return nodeExternal; } + public async Task DetermineBunRuntimeVersion(IExecutionContext executionContext) + { + string bunExternal = "bun"; + + if (FeatureManager.IsContainerHooksEnabled(executionContext.Global.Variables)) + { + if (Container.IsAlpine) + { + bunExternal = CheckPlatformForAlpineBunContainer(executionContext); + } + executionContext.Debug($"Running JavaScript Action with Bun runtime: {bunExternal}"); + return bunExternal; + } + + // Best effort to determine a compatible bun runtime + // Check if we're in an Alpine container + var osReleaseIdCmd = "sh -c \"cat /etc/*release | grep ^ID\""; + var dockerManager = HostContext.GetService(); + + var output = new List(); + var execExitCode = await dockerManager.DockerExec(executionContext, Container.ContainerId, string.Empty, osReleaseIdCmd, output); + if (execExitCode == 0) + { + foreach (var line in output) + { + executionContext.Debug(line); + if (line.ToLower().Contains("alpine")) + { + bunExternal = CheckPlatformForAlpineBunContainer(executionContext); + return bunExternal; + } + } + } + executionContext.Debug($"Running JavaScript Action with Bun runtime: {bunExternal}"); + return bunExternal; + } + + private string CheckPlatformForAlpineBunContainer(IExecutionContext executionContext) + { + // Check for Alpine container compatibility + if (!Constants.Runner.PlatformArchitecture.Equals(Constants.Architecture.X64) && + !Constants.Runner.PlatformArchitecture.Equals(Constants.Architecture.Arm64)) + { + var os = Constants.Runner.Platform.ToString(); + var arch = Constants.Runner.PlatformArchitecture.ToString(); + var msg = $"Bun Actions in Alpine containers are only supported on x64 and ARM64 Linux runners. Detected {os} {arch}"; + throw new NotSupportedException(msg); + } + string bunExternal = "bun_alpine"; + executionContext.Debug($"Container distribution is alpine. Running JavaScript Action with Bun runtime: {bunExternal}"); + return bunExternal; + } + public async Task ExecuteAsync(IExecutionContext context, string workingDirectory, string fileName, diff --git a/src/Test/L0/Worker/ActionManifestManagerL0.cs b/src/Test/L0/Worker/ActionManifestManagerL0.cs index b5da3b30498..65562471d44 100644 --- a/src/Test/L0/Worker/ActionManifestManagerL0.cs +++ b/src/Test/L0/Worker/ActionManifestManagerL0.cs @@ -547,6 +547,49 @@ public void Load_Node24Action() } } + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Load_BunAction() + { + try + { + //Arrange + Setup(); + + var actionManifest = new ActionManifestManager(); + actionManifest.Initialize(_hc); + + //Act + var result = actionManifest.Load(_ec.Object, Path.Combine(TestUtil.GetTestDataPath(), "bunaction.yml")); + + //Assert + Assert.Equal("Hello World", result.Name); + Assert.Equal("Greet the world and record the time", result.Description); + Assert.Equal(2, result.Inputs.Count); + Assert.Equal("greeting", result.Inputs[0].Key.AssertString("key").Value); + Assert.Equal("Hello", result.Inputs[0].Value.AssertString("value").Value); + Assert.Equal("entryPoint", result.Inputs[1].Key.AssertString("key").Value); + Assert.Equal("", result.Inputs[1].Value.AssertString("value").Value); + Assert.Equal(1, result.Deprecated.Count); + + Assert.True(result.Deprecated.ContainsKey("greeting")); + result.Deprecated.TryGetValue("greeting", out string value); + Assert.Equal("This property has been deprecated", value); + + Assert.Equal(ActionExecutionType.NodeJS, result.Execution.ExecutionType); + + var nodeAction = result.Execution as NodeJSActionExecutionData; + + Assert.Equal("main.js", nodeAction.Script); + Assert.Equal("bun", nodeAction.NodeVersion); + } + finally + { + Teardown(); + } + } + [Fact] [Trait("Level", "L0")] [Trait("Category", "Worker")] @@ -803,7 +846,7 @@ public void Load_CompositeActionNoUsing() //Assert var err = Assert.Throws(() => actionManifest.Load(_ec.Object, action_path)); Assert.Contains($"Failed to load {action_path}", err.Message); - _ec.Verify(x => x.AddIssue(It.Is(s => s.Message.Contains("Missing 'using' value. 'using' requires 'composite', 'docker', 'node12', 'node16', 'node20' or 'node24'.")), It.IsAny()), Times.Once); + _ec.Verify(x => x.AddIssue(It.Is(s => s.Message.Contains("Missing 'using' value. 'using' requires 'composite', 'docker', 'node12', 'node16', 'node20', 'node24' or 'bun'.")), It.IsAny()), Times.Once); } finally { diff --git a/src/Test/L0/Worker/ActionManifestManagerLegacyL0.cs b/src/Test/L0/Worker/ActionManifestManagerLegacyL0.cs index c11d4b9b621..04471cc0598 100644 --- a/src/Test/L0/Worker/ActionManifestManagerLegacyL0.cs +++ b/src/Test/L0/Worker/ActionManifestManagerLegacyL0.cs @@ -545,6 +545,49 @@ public void Load_Node24Action() } } + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Load_BunAction() + { + try + { + //Arrange + Setup(); + + var actionManifest = new ActionManifestManagerLegacy(); + actionManifest.Initialize(_hc); + + //Act + var result = actionManifest.Load(_ec.Object, Path.Combine(TestUtil.GetTestDataPath(), "bunaction.yml")); + + //Assert + Assert.Equal("Hello World", result.Name); + Assert.Equal("Greet the world and record the time", result.Description); + Assert.Equal(2, result.Inputs.Count); + Assert.Equal("greeting", result.Inputs[0].Key.AssertString("key").Value); + Assert.Equal("Hello", result.Inputs[0].Value.AssertString("value").Value); + Assert.Equal("entryPoint", result.Inputs[1].Key.AssertString("key").Value); + Assert.Equal("", result.Inputs[1].Value.AssertString("value").Value); + Assert.Equal(1, result.Deprecated.Count); + + Assert.True(result.Deprecated.ContainsKey("greeting")); + result.Deprecated.TryGetValue("greeting", out string value); + Assert.Equal("This property has been deprecated", value); + + Assert.Equal(ActionExecutionType.NodeJS, result.Execution.ExecutionType); + + var nodeAction = result.Execution as NodeJSActionExecutionData; + + Assert.Equal("main.js", nodeAction.Script); + Assert.Equal("bun", nodeAction.NodeVersion); + } + finally + { + Teardown(); + } + } + [Fact] [Trait("Level", "L0")] [Trait("Category", "Worker")] @@ -801,7 +844,7 @@ public void Load_CompositeActionNoUsing() //Assert var err = Assert.Throws(() => actionManifest.Load(_ec.Object, action_path)); Assert.Contains($"Failed to load {action_path}", err.Message); - _ec.Verify(x => x.AddIssue(It.Is(s => s.Message.Contains("Missing 'using' value. 'using' requires 'composite', 'docker', 'node12', 'node16', 'node20' or 'node24'.")), It.IsAny()), Times.Once); + _ec.Verify(x => x.AddIssue(It.Is(s => s.Message.Contains("Missing 'using' value. 'using' requires 'composite', 'docker', 'node12', 'node16', 'node20', 'node24' or 'bun'.")), It.IsAny()), Times.Once); } finally { diff --git a/src/Test/L0/Worker/Handlers/NodeScriptActionHandlerL0.cs b/src/Test/L0/Worker/Handlers/NodeScriptActionHandlerL0.cs new file mode 100644 index 00000000000..60275c9065d --- /dev/null +++ b/src/Test/L0/Worker/Handlers/NodeScriptActionHandlerL0.cs @@ -0,0 +1,242 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Runtime.CompilerServices; +using System.Threading.Tasks; +using GitHub.DistributedTask.Pipelines.ContextData; +using GitHub.DistributedTask.WebApi; +using GitHub.Runner.Common; +using GitHub.Runner.Sdk; +using GitHub.Runner.Worker; +using GitHub.Runner.Worker.Handlers; +using Moq; +using Xunit; +using GitHub.Runner.Common.Tests; + +namespace GitHub.Runner.Common.Tests.Worker.Handlers +{ + public sealed class NodeScriptActionHandlerL0 + { + private Mock _ec; + private Mock _stepHost; + + private TestHostContext CreateTestContext([CallerMemberName] String testName = "") + { + var hc = new TestHostContext(this, testName); + _ec = new Mock(); + _ec.SetupAllProperties(); + var globalContext = new GlobalContext + { + WriteDebug = true, + PrependPath = new List(), + Endpoints = new List() + }; + globalContext.Endpoints.Add(new ServiceEndpoint() + { + Name = WellKnownServiceEndpointNames.SystemVssConnection, + Url = new Uri("https://pipelines.actions.githubusercontent.com"), + Authorization = new EndpointAuthorization() + { + Scheme = "Test", + Parameters = { + {EndpointAuthorizationParameters.AccessToken, "token"} + } + }, + Data = new Dictionary() + }); + _ec.Setup(x => x.Global).Returns(globalContext); + _ec.Object.Global.Variables = new Variables(hc, new Dictionary()); + _ec.Setup(x => x.StepTelemetry).Returns(new ActionsStepTelemetry()); + var forceCompletedSource = new System.Threading.Tasks.TaskCompletionSource(); + _ec.Setup(x => x.ForceCompleted).Returns(forceCompletedSource.Task); + _ec.Setup(x => x.CancellationToken).Returns(System.Threading.CancellationToken.None); + _ec.Setup(x => x.GetGitHubContext(It.IsAny())).Returns(key => null); + _ec.Setup(x => x.ExpressionValues).Returns(new DictionaryContextData()); + + var trace = hc.GetTrace(); + _ec.Setup(x => x.Write(It.IsAny(), It.IsAny())).Callback((string tag, string message) => { trace.Info($"[{tag}]{message}"); }); + + _stepHost = new Mock(); + _stepHost.Setup(x => x.ResolvePathForStepHost(It.IsAny(), It.IsAny())) + .Returns((ec, path) => path); + + hc.EnqueueInstance(new Mock().Object); + return hc; + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task RunAsync_BunRuntime_FeatureFlagDisabled_ThrowsNotSupportedException() + { + using (TestHostContext hc = CreateTestContext()) + { + // Arrange + var actionDirectory = Path.Combine(TestUtil.GetTestDataPath()); + var mainJsPath = Path.Combine(actionDirectory, "main.js"); + if (!File.Exists(mainJsPath)) + { + File.WriteAllText(mainJsPath, "// test file"); + } + + var handler = new NodeScriptActionHandler(); + handler.Initialize(hc); + handler.ExecutionContext = _ec.Object; + handler.StepHost = _stepHost.Object; + handler.ActionDirectory = actionDirectory; + handler.Data = new NodeJSActionExecutionData + { + NodeVersion = "bun", + Script = "main.js" + }; + handler.Inputs = new Dictionary(); + handler.Environment = new Dictionary(); + + // Feature flag is not set (defaults to false) + _ec.Object.Global.Variables = new Variables(hc, new Dictionary()); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => await handler.RunAsync(ActionRunStage.Main)); + Assert.Contains(Constants.Runner.Features.AllowBunRuntime, exception.Message); + Assert.Contains("not enabled", exception.Message); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task RunAsync_BunRuntime_FeatureFlagEnabled_CallsDetermineBunRuntimeVersion() + { + using (TestHostContext hc = CreateTestContext()) + { + // Arrange + var actionDirectory = Path.Combine(TestUtil.GetTestDataPath()); + var mainJsPath = Path.Combine(actionDirectory, "main.js"); + if (!File.Exists(mainJsPath)) + { + File.WriteAllText(mainJsPath, "// test file"); + } + + var handler = new NodeScriptActionHandler(); + handler.Initialize(hc); + handler.ExecutionContext = _ec.Object; + handler.StepHost = _stepHost.Object; + handler.ActionDirectory = actionDirectory; + handler.Data = new NodeJSActionExecutionData + { + NodeVersion = "bun", + Script = "main.js" + }; + handler.Inputs = new Dictionary(); + handler.Environment = new Dictionary(); + + // Enable feature flag + var variables = new Dictionary + { + [Constants.Runner.Features.AllowBunRuntime] = new VariableValue("true") + }; + _ec.Object.Global.Variables = new Variables(hc, variables); + + _stepHost.Setup(x => x.DetermineBunRuntimeVersion(It.IsAny())).ReturnsAsync("bun"); + _stepHost.Setup(x => x.ExecuteAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())).ReturnsAsync(0); + + // Act + await handler.RunAsync(ActionRunStage.Main); + + // Assert + _stepHost.Verify(x => x.DetermineBunRuntimeVersion(_ec.Object), Times.Once); + _stepHost.Verify(x => x.ExecuteAsync( + It.IsAny(), + It.IsAny(), + It.Is(f => f.Contains("bun") && f.Contains("bin")), + It.IsAny(), + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny()), Times.Once); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task RunAsync_BunRuntime_FeatureFlagEnabled_SetsCorrectFilePath() + { + using (TestHostContext hc = CreateTestContext()) + { + // Arrange + var actionDirectory = Path.Combine(TestUtil.GetTestDataPath()); + var mainJsPath = Path.Combine(actionDirectory, "main.js"); + if (!File.Exists(mainJsPath)) + { + File.WriteAllText(mainJsPath, "// test file"); + } + + var handler = new NodeScriptActionHandler(); + handler.Initialize(hc); + handler.ExecutionContext = _ec.Object; + handler.StepHost = _stepHost.Object; + handler.ActionDirectory = actionDirectory; + handler.Data = new NodeJSActionExecutionData + { + NodeVersion = "bun", + Script = "main.js" + }; + handler.Inputs = new Dictionary(); + handler.Environment = new Dictionary(); + + // Enable feature flag + var variables = new Dictionary + { + [Constants.Runner.Features.AllowBunRuntime] = new VariableValue("true") + }; + _ec.Object.Global.Variables = new Variables(hc, variables); + + string capturedFilePath = null; + _stepHost.Setup(x => x.DetermineBunRuntimeVersion(It.IsAny())).ReturnsAsync("bun"); + _stepHost.Setup(x => x.ExecuteAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Callback, bool, System.Text.Encoding, bool, bool, string, System.Threading.CancellationToken>( + (ec, workingDir, fileName, args, env, requireExitCodeZero, outputEncoding, killProcessOnCancel, inheritConsoleHandler, standardInInput, cancellationToken) => + { + capturedFilePath = fileName; + }) + .ReturnsAsync(0); + + // Act + await handler.RunAsync(ActionRunStage.Main); + + // Assert + Assert.NotNull(capturedFilePath); + Assert.Contains("bun", capturedFilePath); + Assert.Contains("bin", capturedFilePath); + var externalsDir = hc.GetDirectory(WellKnownDirectory.Externals); + Assert.StartsWith(externalsDir, capturedFilePath); + } + } + } +} diff --git a/src/Test/L0/Worker/StepHostL0.cs b/src/Test/L0/Worker/StepHostL0.cs index bac7d41d90a..a39e164142f 100644 --- a/src/Test/L0/Worker/StepHostL0.cs +++ b/src/Test/L0/Worker/StepHostL0.cs @@ -4,6 +4,7 @@ using System.Threading.Tasks; using Moq; using Xunit; +using GitHub.Runner.Common; using GitHub.Runner.Worker; using GitHub.Runner.Worker.Handlers; using GitHub.Runner.Worker.Container; @@ -216,6 +217,131 @@ public async Task DetermineNode24RuntimeVersionInUnknownContainerAsync() Assert.Equal("node24", nodeVersion); } } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task DetermineBunRuntimeVersionInDefaultStepHostAsync() + { + using (TestHostContext hc = CreateTestContext()) + { + // Arrange. + var sh = new DefaultStepHost(); + sh.Initialize(hc); + + // Act. + var bunVersion = await sh.DetermineBunRuntimeVersion(_ec.Object); + + // Assert. + Assert.Equal("bun", bunVersion); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task DetermineBunRuntimeVersionInContainerAsync() + { + using (TestHostContext hc = CreateTestContext()) + { + // Arrange. + var sh = new ContainerStepHost(); + sh.Initialize(hc); + sh.Container = new ContainerInfo() { ContainerId = "1234abcd" }; + + _dc.Setup(d => d.DockerExec(_ec.Object, It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny>())) + .ReturnsAsync(0); + + // Act. + var bunVersion = await sh.DetermineBunRuntimeVersion(_ec.Object); + + // Assert. + Assert.Equal("bun", bunVersion); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task DetermineBunRuntimeVersionInAlpineContainerAsync() + { + using (TestHostContext hc = CreateTestContext()) + { + // Arrange. + var sh = new ContainerStepHost(); + sh.Initialize(hc); + sh.Container = new ContainerInfo() { ContainerId = "1234abcd" }; + + _dc.Setup(d => d.DockerExec(_ec.Object, It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny>())) + .Callback((IExecutionContext ec, string id, string options, string command, List output) => + { + output.Add("alpine"); + }) + .ReturnsAsync(0); + + // Act. + var bunVersion = await sh.DetermineBunRuntimeVersion(_ec.Object); + + // Assert. + Assert.Equal("bun_alpine", bunVersion); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task DetermineBunRuntimeVersionInUnknownContainerAsync() + { + using (TestHostContext hc = CreateTestContext()) + { + // Arrange. + var sh = new ContainerStepHost(); + sh.Initialize(hc); + sh.Container = new ContainerInfo() { ContainerId = "1234abcd" }; + + _dc.Setup(d => d.DockerExec(_ec.Object, It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny>())) + .Callback((IExecutionContext ec, string id, string options, string command, List output) => + { + output.Add("ubuntu"); + }) + .ReturnsAsync(0); + + // Act. + var bunVersion = await sh.DetermineBunRuntimeVersion(_ec.Object); + + // Assert. + Assert.Equal("bun", bunVersion); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task DetermineBunRuntimeVersionInAlpineContainerWithContainerHooksAsync() + { + using (TestHostContext hc = CreateTestContext()) + { + // Arrange. + var sh = new ContainerStepHost(); + sh.Initialize(hc); + sh.Container = new ContainerInfo() { ContainerId = "1234abcd", IsAlpine = true }; + _ec.Object.Global.Variables.Set(Constants.Runner.Features.AllowRunnerContainerHooks, "true"); + Environment.SetEnvironmentVariable(Constants.Hooks.ContainerHooksPath, "/some/path"); + + try + { + // Act. + var bunVersion = await sh.DetermineBunRuntimeVersion(_ec.Object); + + // Assert. + Assert.Equal("bun_alpine", bunVersion); + } + finally + { + Environment.SetEnvironmentVariable(Constants.Hooks.ContainerHooksPath, null); + } + } + } #endif } } diff --git a/src/Test/TestData/bunaction.yml b/src/Test/TestData/bunaction.yml new file mode 100644 index 00000000000..0332f6a8abf --- /dev/null +++ b/src/Test/TestData/bunaction.yml @@ -0,0 +1,20 @@ +name: 'Hello World' +description: 'Greet the world and record the time' +author: 'Test Corporation' +inputs: + greeting: # id of input + description: 'The greeting we choose - will print ""{greeting}, World!"" on stdout' + required: true + default: 'Hello' + deprecationMessage: 'This property has been deprecated' + entryPoint: # id of input + description: 'optional docker entrypoint overwrite.' + required: false +outputs: + time: # id of output + description: 'The time we did the greeting' +icon: 'hello.svg' # vector art to display in the GitHub Marketplace +color: 'green' # optional, decorates the entry in the GitHub Marketplace +runs: + using: 'bun' + main: 'main.js' diff --git a/src/Test/TestData/main.js b/src/Test/TestData/main.js new file mode 100644 index 00000000000..b5f02fc04bb --- /dev/null +++ b/src/Test/TestData/main.js @@ -0,0 +1 @@ +// test file \ No newline at end of file