diff --git a/src/Runner.Common/Constants.cs b/src/Runner.Common/Constants.cs index f2d5dd26e..3cc9d28b4 100644 --- a/src/Runner.Common/Constants.cs +++ b/src/Runner.Common/Constants.cs @@ -190,6 +190,10 @@ namespace GitHub.Runner.Common // Feature flags for controlling the migration phases public static readonly string UseNode24ByDefaultFlag = "actions.runner.usenode24bydefault"; public static readonly string RequireNode24Flag = "actions.runner.requirenode24"; + public static readonly string WarnOnNode20Flag = "actions.runner.warnonnode20"; + + // Blog post URL for Node 20 deprecation + public static readonly string Node20DeprecationUrl = "https://github.blog/changelog/2025-09-19-deprecation-of-node-20-on-github-actions-runners/"; } public static readonly string InternalTelemetryIssueDataKey = "_internal_telemetry"; diff --git a/src/Runner.Worker/ExecutionContext.cs b/src/Runner.Worker/ExecutionContext.cs index 2a7cd11fb..53484e6b6 100644 --- a/src/Runner.Worker/ExecutionContext.cs +++ b/src/Runner.Worker/ExecutionContext.cs @@ -856,6 +856,9 @@ namespace GitHub.Runner.Worker // Job level annotations Global.JobAnnotations = new List(); + // Track Node.js 20 actions for deprecation warning + Global.DeprecatedNode20Actions = new HashSet(StringComparer.OrdinalIgnoreCase); + // Job Outputs JobOutputs = new Dictionary(StringComparer.OrdinalIgnoreCase); diff --git a/src/Runner.Worker/GlobalContext.cs b/src/Runner.Worker/GlobalContext.cs index 6d4494843..27c326d68 100644 --- a/src/Runner.Worker/GlobalContext.cs +++ b/src/Runner.Worker/GlobalContext.cs @@ -33,5 +33,6 @@ namespace GitHub.Runner.Worker public bool HasActionManifestMismatch { get; set; } public bool HasDeprecatedSetOutput { get; set; } public bool HasDeprecatedSaveState { get; set; } + public HashSet DeprecatedNode20Actions { get; set; } } } diff --git a/src/Runner.Worker/Handlers/HandlerFactory.cs b/src/Runner.Worker/Handlers/HandlerFactory.cs index ee022ec9d..e9e2a5a60 100644 --- a/src/Runner.Worker/Handlers/HandlerFactory.cs +++ b/src/Runner.Worker/Handlers/HandlerFactory.cs @@ -65,6 +65,20 @@ namespace GitHub.Runner.Worker.Handlers nodeData.NodeVersion = Common.Constants.Runner.NodeMigration.Node20; } + // Track Node.js 20 actions for deprecation annotation + if (string.Equals(nodeData.NodeVersion, Constants.Runner.NodeMigration.Node20, StringComparison.InvariantCultureIgnoreCase)) + { + bool warnOnNode20 = executionContext.Global.Variables?.GetBoolean(Constants.Runner.NodeMigration.WarnOnNode20Flag) ?? false; + if (warnOnNode20) + { + string actionName = GetActionName(action); + if (!string.IsNullOrEmpty(actionName)) + { + executionContext.Global.DeprecatedNode20Actions?.Add(actionName); + } + } + } + // Check if node20 was explicitly specified in the action // We don't modify if node24 was explicitly specified if (string.Equals(nodeData.NodeVersion, Constants.Runner.NodeMigration.Node20, StringComparison.InvariantCultureIgnoreCase)) @@ -90,7 +104,8 @@ namespace GitHub.Runner.Worker.Handlers if (useNode24ByDefault && !requireNode24 && string.Equals(finalNodeVersion, Constants.Runner.NodeMigration.Node24, StringComparison.OrdinalIgnoreCase)) { string infoMessage = "Node 20 is being deprecated. This workflow is running with Node 24 by default. " + - "If you need to temporarily use Node 20, you can set the ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION=true environment variable."; + "If you need to temporarily use Node 20, you can set the ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION=true environment variable. " + + $"For more information see: {Constants.Runner.NodeMigration.Node20DeprecationUrl}"; executionContext.Output(infoMessage); } } @@ -129,5 +144,25 @@ namespace GitHub.Runner.Worker.Handlers handler.LocalActionContainerSetupSteps = localActionContainerSetupSteps; return handler; } + + private static string GetActionName(Pipelines.ActionStepDefinitionReference action) + { + if (action is Pipelines.RepositoryPathReference repoRef) + { + var pathString = string.Empty; + if (!string.IsNullOrEmpty(repoRef.Path)) + { + pathString = string.IsNullOrEmpty(repoRef.Name) + ? repoRef.Path + : $"/{repoRef.Path}"; + } + var repoString = string.IsNullOrEmpty(repoRef.Ref) + ? $"{repoRef.Name}{pathString}" + : $"{repoRef.Name}{pathString}@{repoRef.Ref}"; + return string.IsNullOrEmpty(repoString) ? null : repoString; + } + + return null; + } } } diff --git a/src/Runner.Worker/JobExtension.cs b/src/Runner.Worker/JobExtension.cs index ea36034ec..bd4644766 100644 --- a/src/Runner.Worker/JobExtension.cs +++ b/src/Runner.Worker/JobExtension.cs @@ -735,6 +735,15 @@ namespace GitHub.Runner.Worker context.Global.JobTelemetry.Add(new JobTelemetry() { Type = JobTelemetryType.ConnectivityCheck, Message = $"Fail to check service connectivity. {ex.Message}" }); } } + + // Add deprecation warning annotation for Node.js 20 actions + if (context.Global.DeprecatedNode20Actions?.Count > 0) + { + var sortedActions = context.Global.DeprecatedNode20Actions.OrderBy(a => a, StringComparer.OrdinalIgnoreCase); + var actionsList = string.Join(", ", sortedActions); + var deprecationMessage = $"Node.js 20 actions are deprecated. The following actions are running on Node.js 20 and may not work as expected: {actionsList}. Actions will be forced to run with Node.js 24 by default starting June 2nd, 2025. Please check if updated versions of these actions are available that support Node.js 24. To opt into Node.js 24 now, set the FORCE_JAVASCRIPT_ACTIONS_TO_NODE24=true environment variable on the runner or in your workflow file. Once Node.js 24 becomes the default, you can temporarily opt out by setting ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION=true. For more information see: {Constants.Runner.NodeMigration.Node20DeprecationUrl}"; + context.Warning(deprecationMessage); + } } catch (Exception ex) { diff --git a/src/Test/L0/Worker/HandlerFactoryL0.cs b/src/Test/L0/Worker/HandlerFactoryL0.cs index 37981e46a..85a70ff52 100644 --- a/src/Test/L0/Worker/HandlerFactoryL0.cs +++ b/src/Test/L0/Worker/HandlerFactoryL0.cs @@ -74,7 +74,7 @@ namespace GitHub.Runner.Common.Tests.Worker } } - + [Fact] [Trait("Level", "L0")] @@ -116,5 +116,259 @@ namespace GitHub.Runner.Common.Tests.Worker Assert.Equal("node24", handler.Data.NodeVersion); } } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Node20Action_TrackedWhenWarnFlagEnabled() + { + using (TestHostContext hc = CreateTestContext()) + { + // Arrange. + var hf = new HandlerFactory(); + hf.Initialize(hc); + + var variables = new Dictionary + { + { Constants.Runner.NodeMigration.WarnOnNode20Flag, new VariableValue("true") } + }; + Variables serverVariables = new(hc, variables); + var deprecatedActions = new HashSet(StringComparer.OrdinalIgnoreCase); + + _ec.Setup(x => x.Global).Returns(new GlobalContext() + { + Variables = serverVariables, + EnvironmentVariables = new Dictionary(), + DeprecatedNode20Actions = deprecatedActions + }); + + var actionRef = new RepositoryPathReference + { + Name = "actions/checkout", + Ref = "v4" + }; + + // Act. + var data = new NodeJSActionExecutionData(); + data.NodeVersion = "node20"; + hf.Create( + _ec.Object, + actionRef, + new Mock().Object, + data, + new Dictionary(), + new Dictionary(), + new Variables(hc, new Dictionary()), + "", + new List() + ); + + // Assert. + Assert.Contains("actions/checkout@v4", deprecatedActions); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Node20Action_NotTrackedWhenWarnFlagDisabled() + { + using (TestHostContext hc = CreateTestContext()) + { + // Arrange. + var hf = new HandlerFactory(); + hf.Initialize(hc); + + var variables = new Dictionary(); + Variables serverVariables = new(hc, variables); + var deprecatedActions = new HashSet(StringComparer.OrdinalIgnoreCase); + + _ec.Setup(x => x.Global).Returns(new GlobalContext() + { + Variables = serverVariables, + EnvironmentVariables = new Dictionary(), + DeprecatedNode20Actions = deprecatedActions + }); + + var actionRef = new RepositoryPathReference + { + Name = "actions/checkout", + Ref = "v4" + }; + + // Act. + var data = new NodeJSActionExecutionData(); + data.NodeVersion = "node20"; + hf.Create( + _ec.Object, + actionRef, + new Mock().Object, + data, + new Dictionary(), + new Dictionary(), + new Variables(hc, new Dictionary()), + "", + new List() + ); + + // Assert - should not track when flag is disabled + Assert.Empty(deprecatedActions); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Node24Action_NotTrackedEvenWhenWarnFlagEnabled() + { + using (TestHostContext hc = CreateTestContext()) + { + // Arrange. + var hf = new HandlerFactory(); + hf.Initialize(hc); + + var variables = new Dictionary + { + { Constants.Runner.NodeMigration.WarnOnNode20Flag, new VariableValue("true") } + }; + Variables serverVariables = new(hc, variables); + var deprecatedActions = new HashSet(StringComparer.OrdinalIgnoreCase); + + _ec.Setup(x => x.Global).Returns(new GlobalContext() + { + Variables = serverVariables, + EnvironmentVariables = new Dictionary(), + DeprecatedNode20Actions = deprecatedActions + }); + + var actionRef = new RepositoryPathReference + { + Name = "actions/checkout", + Ref = "v5" + }; + + // Act. + var data = new NodeJSActionExecutionData(); + data.NodeVersion = "node24"; + hf.Create( + _ec.Object, + actionRef, + new Mock().Object, + data, + new Dictionary(), + new Dictionary(), + new Variables(hc, new Dictionary()), + "", + new List() + ); + + // Assert - node24 actions should not be tracked + Assert.Empty(deprecatedActions); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Node12Action_TrackedAsDeprecatedWhenWarnFlagEnabled() + { + using (TestHostContext hc = CreateTestContext()) + { + // Arrange. + var hf = new HandlerFactory(); + hf.Initialize(hc); + + var variables = new Dictionary + { + { Constants.Runner.NodeMigration.WarnOnNode20Flag, new VariableValue("true") } + }; + Variables serverVariables = new(hc, variables); + var deprecatedActions = new HashSet(StringComparer.OrdinalIgnoreCase); + + _ec.Setup(x => x.Global).Returns(new GlobalContext() + { + Variables = serverVariables, + EnvironmentVariables = new Dictionary(), + DeprecatedNode20Actions = deprecatedActions + }); + + var actionRef = new RepositoryPathReference + { + Name = "some-org/old-action", + Ref = "v1" + }; + + // Act - node12 gets migrated to node20, then should be tracked + var data = new NodeJSActionExecutionData(); + data.NodeVersion = "node12"; + hf.Create( + _ec.Object, + actionRef, + new Mock().Object, + data, + new Dictionary(), + new Dictionary(), + new Variables(hc, new Dictionary()), + "", + new List() + ); + + // Assert - node12 gets migrated to node20 and should be tracked + Assert.Contains("some-org/old-action@v1", deprecatedActions); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void LocalNode20Action_TrackedWhenWarnFlagEnabled() + { + using (TestHostContext hc = CreateTestContext()) + { + // Arrange. + var hf = new HandlerFactory(); + hf.Initialize(hc); + + var variables = new Dictionary + { + { Constants.Runner.NodeMigration.WarnOnNode20Flag, new VariableValue("true") } + }; + Variables serverVariables = new(hc, variables); + var deprecatedActions = new HashSet(StringComparer.OrdinalIgnoreCase); + + _ec.Setup(x => x.Global).Returns(new GlobalContext() + { + Variables = serverVariables, + EnvironmentVariables = new Dictionary(), + DeprecatedNode20Actions = deprecatedActions + }); + + // Local action: Name is empty, Path is the local path + var actionRef = new RepositoryPathReference + { + Name = "", + Path = "./.github/actions/my-action", + RepositoryType = "self" + }; + + // Act. + var data = new NodeJSActionExecutionData(); + data.NodeVersion = "node20"; + hf.Create( + _ec.Object, + actionRef, + new Mock().Object, + data, + new Dictionary(), + new Dictionary(), + new Variables(hc, new Dictionary()), + "", + new List() + ); + + // Assert - local action should be tracked with its path + Assert.Contains("./.github/actions/my-action", deprecatedActions); + } + } } }