Skip to content

Commit 9426c35

Browse files
authored
Add Node.js 20 deprecation warning annotation (Phase 1) (#4242)
1 parent 72189aa commit 9426c35

File tree

6 files changed

+308
-2
lines changed

6 files changed

+308
-2
lines changed

src/Runner.Common/Constants.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,10 @@ public static class NodeMigration
190190
// Feature flags for controlling the migration phases
191191
public static readonly string UseNode24ByDefaultFlag = "actions.runner.usenode24bydefault";
192192
public static readonly string RequireNode24Flag = "actions.runner.requirenode24";
193+
public static readonly string WarnOnNode20Flag = "actions.runner.warnonnode20";
194+
195+
// Blog post URL for Node 20 deprecation
196+
public static readonly string Node20DeprecationUrl = "https://github.blog/changelog/2025-09-19-deprecation-of-node-20-on-github-actions-runners/";
193197
}
194198

195199
public static readonly string InternalTelemetryIssueDataKey = "_internal_telemetry";

src/Runner.Worker/ExecutionContext.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -856,6 +856,9 @@ public void InitializeJob(Pipelines.AgentJobRequestMessage message, Cancellation
856856
// Job level annotations
857857
Global.JobAnnotations = new List<Annotation>();
858858

859+
// Track Node.js 20 actions for deprecation warning
860+
Global.DeprecatedNode20Actions = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
861+
859862
// Job Outputs
860863
JobOutputs = new Dictionary<string, VariableValue>(StringComparer.OrdinalIgnoreCase);
861864

src/Runner.Worker/GlobalContext.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,5 +33,6 @@ public sealed class GlobalContext
3333
public bool HasActionManifestMismatch { get; set; }
3434
public bool HasDeprecatedSetOutput { get; set; }
3535
public bool HasDeprecatedSaveState { get; set; }
36+
public HashSet<string> DeprecatedNode20Actions { get; set; }
3637
}
3738
}

src/Runner.Worker/Handlers/HandlerFactory.cs

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,20 @@ public IHandler Create(
6565
nodeData.NodeVersion = Common.Constants.Runner.NodeMigration.Node20;
6666
}
6767

68+
// Track Node.js 20 actions for deprecation annotation
69+
if (string.Equals(nodeData.NodeVersion, Constants.Runner.NodeMigration.Node20, StringComparison.InvariantCultureIgnoreCase))
70+
{
71+
bool warnOnNode20 = executionContext.Global.Variables?.GetBoolean(Constants.Runner.NodeMigration.WarnOnNode20Flag) ?? false;
72+
if (warnOnNode20)
73+
{
74+
string actionName = GetActionName(action);
75+
if (!string.IsNullOrEmpty(actionName))
76+
{
77+
executionContext.Global.DeprecatedNode20Actions?.Add(actionName);
78+
}
79+
}
80+
}
81+
6882
// Check if node20 was explicitly specified in the action
6983
// We don't modify if node24 was explicitly specified
7084
if (string.Equals(nodeData.NodeVersion, Constants.Runner.NodeMigration.Node20, StringComparison.InvariantCultureIgnoreCase))
@@ -90,7 +104,8 @@ public IHandler Create(
90104
if (useNode24ByDefault && !requireNode24 && string.Equals(finalNodeVersion, Constants.Runner.NodeMigration.Node24, StringComparison.OrdinalIgnoreCase))
91105
{
92106
string infoMessage = "Node 20 is being deprecated. This workflow is running with Node 24 by default. " +
93-
"If you need to temporarily use Node 20, you can set the ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION=true environment variable.";
107+
"If you need to temporarily use Node 20, you can set the ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION=true environment variable. " +
108+
$"For more information see: {Constants.Runner.NodeMigration.Node20DeprecationUrl}";
94109
executionContext.Output(infoMessage);
95110
}
96111
}
@@ -129,5 +144,25 @@ public IHandler Create(
129144
handler.LocalActionContainerSetupSteps = localActionContainerSetupSteps;
130145
return handler;
131146
}
147+
148+
private static string GetActionName(Pipelines.ActionStepDefinitionReference action)
149+
{
150+
if (action is Pipelines.RepositoryPathReference repoRef)
151+
{
152+
var pathString = string.Empty;
153+
if (!string.IsNullOrEmpty(repoRef.Path))
154+
{
155+
pathString = string.IsNullOrEmpty(repoRef.Name)
156+
? repoRef.Path
157+
: $"/{repoRef.Path}";
158+
}
159+
var repoString = string.IsNullOrEmpty(repoRef.Ref)
160+
? $"{repoRef.Name}{pathString}"
161+
: $"{repoRef.Name}{pathString}@{repoRef.Ref}";
162+
return string.IsNullOrEmpty(repoString) ? null : repoString;
163+
}
164+
165+
return null;
166+
}
132167
}
133168
}

src/Runner.Worker/JobExtension.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -735,6 +735,15 @@ public async Task FinalizeJob(IExecutionContext jobContext, Pipelines.AgentJobRe
735735
context.Global.JobTelemetry.Add(new JobTelemetry() { Type = JobTelemetryType.ConnectivityCheck, Message = $"Fail to check service connectivity. {ex.Message}" });
736736
}
737737
}
738+
739+
// Add deprecation warning annotation for Node.js 20 actions
740+
if (context.Global.DeprecatedNode20Actions?.Count > 0)
741+
{
742+
var sortedActions = context.Global.DeprecatedNode20Actions.OrderBy(a => a, StringComparer.OrdinalIgnoreCase);
743+
var actionsList = string.Join(", ", sortedActions);
744+
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}";
745+
context.Warning(deprecationMessage);
746+
}
738747
}
739748
catch (Exception ex)
740749
{

src/Test/L0/Worker/HandlerFactoryL0.cs

Lines changed: 255 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ public void IsNodeVersionUpgraded(string inputVersion, string expectedVersion)
7474
}
7575
}
7676

77-
77+
7878

7979
[Fact]
8080
[Trait("Level", "L0")]
@@ -116,5 +116,259 @@ public void Node24ExplicitlyRequested_HonoredByDefault()
116116
Assert.Equal("node24", handler.Data.NodeVersion);
117117
}
118118
}
119+
120+
[Fact]
121+
[Trait("Level", "L0")]
122+
[Trait("Category", "Worker")]
123+
public void Node20Action_TrackedWhenWarnFlagEnabled()
124+
{
125+
using (TestHostContext hc = CreateTestContext())
126+
{
127+
// Arrange.
128+
var hf = new HandlerFactory();
129+
hf.Initialize(hc);
130+
131+
var variables = new Dictionary<string, VariableValue>
132+
{
133+
{ Constants.Runner.NodeMigration.WarnOnNode20Flag, new VariableValue("true") }
134+
};
135+
Variables serverVariables = new(hc, variables);
136+
var deprecatedActions = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
137+
138+
_ec.Setup(x => x.Global).Returns(new GlobalContext()
139+
{
140+
Variables = serverVariables,
141+
EnvironmentVariables = new Dictionary<string, string>(),
142+
DeprecatedNode20Actions = deprecatedActions
143+
});
144+
145+
var actionRef = new RepositoryPathReference
146+
{
147+
Name = "actions/checkout",
148+
Ref = "v4"
149+
};
150+
151+
// Act.
152+
var data = new NodeJSActionExecutionData();
153+
data.NodeVersion = "node20";
154+
hf.Create(
155+
_ec.Object,
156+
actionRef,
157+
new Mock<IStepHost>().Object,
158+
data,
159+
new Dictionary<string, string>(),
160+
new Dictionary<string, string>(),
161+
new Variables(hc, new Dictionary<string, VariableValue>()),
162+
"",
163+
new List<JobExtensionRunner>()
164+
);
165+
166+
// Assert.
167+
Assert.Contains("actions/checkout@v4", deprecatedActions);
168+
}
169+
}
170+
171+
[Fact]
172+
[Trait("Level", "L0")]
173+
[Trait("Category", "Worker")]
174+
public void Node20Action_NotTrackedWhenWarnFlagDisabled()
175+
{
176+
using (TestHostContext hc = CreateTestContext())
177+
{
178+
// Arrange.
179+
var hf = new HandlerFactory();
180+
hf.Initialize(hc);
181+
182+
var variables = new Dictionary<string, VariableValue>();
183+
Variables serverVariables = new(hc, variables);
184+
var deprecatedActions = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
185+
186+
_ec.Setup(x => x.Global).Returns(new GlobalContext()
187+
{
188+
Variables = serverVariables,
189+
EnvironmentVariables = new Dictionary<string, string>(),
190+
DeprecatedNode20Actions = deprecatedActions
191+
});
192+
193+
var actionRef = new RepositoryPathReference
194+
{
195+
Name = "actions/checkout",
196+
Ref = "v4"
197+
};
198+
199+
// Act.
200+
var data = new NodeJSActionExecutionData();
201+
data.NodeVersion = "node20";
202+
hf.Create(
203+
_ec.Object,
204+
actionRef,
205+
new Mock<IStepHost>().Object,
206+
data,
207+
new Dictionary<string, string>(),
208+
new Dictionary<string, string>(),
209+
new Variables(hc, new Dictionary<string, VariableValue>()),
210+
"",
211+
new List<JobExtensionRunner>()
212+
);
213+
214+
// Assert - should not track when flag is disabled
215+
Assert.Empty(deprecatedActions);
216+
}
217+
}
218+
219+
[Fact]
220+
[Trait("Level", "L0")]
221+
[Trait("Category", "Worker")]
222+
public void Node24Action_NotTrackedEvenWhenWarnFlagEnabled()
223+
{
224+
using (TestHostContext hc = CreateTestContext())
225+
{
226+
// Arrange.
227+
var hf = new HandlerFactory();
228+
hf.Initialize(hc);
229+
230+
var variables = new Dictionary<string, VariableValue>
231+
{
232+
{ Constants.Runner.NodeMigration.WarnOnNode20Flag, new VariableValue("true") }
233+
};
234+
Variables serverVariables = new(hc, variables);
235+
var deprecatedActions = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
236+
237+
_ec.Setup(x => x.Global).Returns(new GlobalContext()
238+
{
239+
Variables = serverVariables,
240+
EnvironmentVariables = new Dictionary<string, string>(),
241+
DeprecatedNode20Actions = deprecatedActions
242+
});
243+
244+
var actionRef = new RepositoryPathReference
245+
{
246+
Name = "actions/checkout",
247+
Ref = "v5"
248+
};
249+
250+
// Act.
251+
var data = new NodeJSActionExecutionData();
252+
data.NodeVersion = "node24";
253+
hf.Create(
254+
_ec.Object,
255+
actionRef,
256+
new Mock<IStepHost>().Object,
257+
data,
258+
new Dictionary<string, string>(),
259+
new Dictionary<string, string>(),
260+
new Variables(hc, new Dictionary<string, VariableValue>()),
261+
"",
262+
new List<JobExtensionRunner>()
263+
);
264+
265+
// Assert - node24 actions should not be tracked
266+
Assert.Empty(deprecatedActions);
267+
}
268+
}
269+
270+
[Fact]
271+
[Trait("Level", "L0")]
272+
[Trait("Category", "Worker")]
273+
public void Node12Action_TrackedAsDeprecatedWhenWarnFlagEnabled()
274+
{
275+
using (TestHostContext hc = CreateTestContext())
276+
{
277+
// Arrange.
278+
var hf = new HandlerFactory();
279+
hf.Initialize(hc);
280+
281+
var variables = new Dictionary<string, VariableValue>
282+
{
283+
{ Constants.Runner.NodeMigration.WarnOnNode20Flag, new VariableValue("true") }
284+
};
285+
Variables serverVariables = new(hc, variables);
286+
var deprecatedActions = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
287+
288+
_ec.Setup(x => x.Global).Returns(new GlobalContext()
289+
{
290+
Variables = serverVariables,
291+
EnvironmentVariables = new Dictionary<string, string>(),
292+
DeprecatedNode20Actions = deprecatedActions
293+
});
294+
295+
var actionRef = new RepositoryPathReference
296+
{
297+
Name = "some-org/old-action",
298+
Ref = "v1"
299+
};
300+
301+
// Act - node12 gets migrated to node20, then should be tracked
302+
var data = new NodeJSActionExecutionData();
303+
data.NodeVersion = "node12";
304+
hf.Create(
305+
_ec.Object,
306+
actionRef,
307+
new Mock<IStepHost>().Object,
308+
data,
309+
new Dictionary<string, string>(),
310+
new Dictionary<string, string>(),
311+
new Variables(hc, new Dictionary<string, VariableValue>()),
312+
"",
313+
new List<JobExtensionRunner>()
314+
);
315+
316+
// Assert - node12 gets migrated to node20 and should be tracked
317+
Assert.Contains("some-org/old-action@v1", deprecatedActions);
318+
}
319+
}
320+
321+
[Fact]
322+
[Trait("Level", "L0")]
323+
[Trait("Category", "Worker")]
324+
public void LocalNode20Action_TrackedWhenWarnFlagEnabled()
325+
{
326+
using (TestHostContext hc = CreateTestContext())
327+
{
328+
// Arrange.
329+
var hf = new HandlerFactory();
330+
hf.Initialize(hc);
331+
332+
var variables = new Dictionary<string, VariableValue>
333+
{
334+
{ Constants.Runner.NodeMigration.WarnOnNode20Flag, new VariableValue("true") }
335+
};
336+
Variables serverVariables = new(hc, variables);
337+
var deprecatedActions = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
338+
339+
_ec.Setup(x => x.Global).Returns(new GlobalContext()
340+
{
341+
Variables = serverVariables,
342+
EnvironmentVariables = new Dictionary<string, string>(),
343+
DeprecatedNode20Actions = deprecatedActions
344+
});
345+
346+
// Local action: Name is empty, Path is the local path
347+
var actionRef = new RepositoryPathReference
348+
{
349+
Name = "",
350+
Path = "./.github/actions/my-action",
351+
RepositoryType = "self"
352+
};
353+
354+
// Act.
355+
var data = new NodeJSActionExecutionData();
356+
data.NodeVersion = "node20";
357+
hf.Create(
358+
_ec.Object,
359+
actionRef,
360+
new Mock<IStepHost>().Object,
361+
data,
362+
new Dictionary<string, string>(),
363+
new Dictionary<string, string>(),
364+
new Variables(hc, new Dictionary<string, VariableValue>()),
365+
"",
366+
new List<JobExtensionRunner>()
367+
);
368+
369+
// Assert - local action should be tracked with its path
370+
Assert.Contains("./.github/actions/my-action", deprecatedActions);
371+
}
372+
}
119373
}
120374
}

0 commit comments

Comments
 (0)