1// SPDX-License-Identifier: MIT
2
3package executor
4
5import (
6 "fmt"
7 "os"
8 "path/filepath"
9 "strings"
10
11 "bnema.dev/ermite-actions/action"
12)
13
14const (
15 node20Name = "node20"
16 node24Name = "node24"
17 // Node20DeprecationURL is Constants.Runner.NodeMigration.Node20DeprecationUrl.
18 node20DeprecationURL = "https://github.blog/changelog/2025-09-19-deprecation-of-node-20-on-github-actions-runners/"
19 forceNode24Var = "FORCE_JAVASCRIPT_ACTIONS_TO_NODE24"
20 allowUnsecureVar = "ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION"
21)
22
23// convertToBool is StringUtil.ConvertToBoolean with a false default.
24func convertToBool(v string) bool {
25 switch strings.ToLower(v) {
26 case "1", "true", "$true":
27 return true
28 }
29 return false
30}
31
32// envFlag is NodeUtil.GetEnvironmentVariableDetails: the value comes from the
33// workflow's environment (the env context of the step) when it is set there,
34// from the process of the runner otherwise.
35type envFlag struct{ on, fromWorkflow, fromSystem bool }
36
37func (j *job) nodeEnvFlag(name string, wf *ordered) envFlag {
38 var f envFlag
39 v, found := wf.Get(name)
40 if found {
41 f.fromWorkflow = true
42 f.on = convertToBool(v)
43 }
44 sys := envValue(j.baseEnv, name)
45 f.fromSystem = sys != ""
46 if !found {
47 f.on = convertToBool(sys)
48 }
49 return f
50}
51
52// nodeVersionDefault is NodeUtil.DetermineActionsNodeVersion
53// (NodeUtil.cs:50-110) for an action that says node20: the version the action
54// runs on and the warning of a conflicting configuration. useNode24ByDefault and
55// requireNode24 are the server's flags, ActionsConfig.Node24ByDefault and
56// RequireNode24 here.
57func (j *job) nodeVersionDefault(wf *ordered) (version, warning string) {
58 force := j.nodeEnvFlag(forceNode24Var, wf)
59 allow := j.nodeEnvFlag(allowUnsecureVar, wf)
60 if j.cfg.Actions.RequireNode24 {
61 // Phase 3 (NodeUtil.cs:60-63): node24 whatever the variables say, and
62 // no warning about them.
63 return node24Name, ""
64 }
65 byDefault := j.cfg.Actions.Node24ByDefault
66 bothWorkflow := force.on && allow.on && force.fromWorkflow && allow.fromWorkflow
67 bothSystem := force.on && allow.on && force.fromSystem && allow.fromSystem
68 if bothWorkflow || bothSystem {
69 source := "system"
70 if bothWorkflow {
71 source = "workflow"
72 }
73 def := node20Name
74 if byDefault {
75 def = node24Name
76 }
77 return def, fmt.Sprintf("Both %s and %s environment variables are set to true in the %s environment. This is likely a configuration error. Using the default Node version: %s.",
78 forceNode24Var, allowUnsecureVar, source, def)
79 }
80 if byDefault {
81 if allow.on {
82 return node20Name, ""
83 }
84 return node24Name, ""
85 }
86 if force.on {
87 return node24Name, ""
88 }
89 return node20Name, ""
90}
91
92// nodeVersionFor is the node part of HandlerFactory.Create
93// (HandlerFactory.cs:56-170): node12 and node16 run on node20, node20 on
94// whatever DetermineActionsNodeVersion says, node24 on node24. The result is
95// kept in the step for the handler.
96func (j *job) nodeVersionFor(s *stepRun, def *actionDef) error {
97 r := def.runs()
98 if r == nil || def.kind != kindNode {
99 return nil
100 }
101 v := r.Using
102 if v == "node12" || v == "node16" {
103 v = node20Name
104 }
105 if v == node20Name {
106 final, warn := j.nodeVersionDefault(s.env)
107 v = final
108 if warn != "" {
109 j.tagged(s.num, "##[warning]", warn)
110 }
111 if final == node24Name {
112 j.mu.Lock()
113 j.upgradedToNode24 = appendUnique(j.upgradedToNode24, actionName(s.n))
114 j.mu.Unlock()
115 if j.cfg.Actions.Node24ByDefault && !j.cfg.Actions.RequireNode24 {
116 j.out(s.num, "Node 20 is being deprecated. This workflow is running with Node 24 by default. "+
117 "If you need to temporarily use Node 20, you can set the ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION=true environment variable. "+
118 "For more information see: "+node20DeprecationURL)
119 }
120 }
121 }
122 s.nodeVersion = v
123 return nil
124}
125
126func appendUnique(l []string, v string) []string {
127 if v == "" {
128 return l
129 }
130 for _, x := range l {
131 if x == v {
132 return l
133 }
134 }
135 return append(l, v)
136}
137
138// actionName is HandlerFactory.GetActionName: "Name[/Path]@Ref".
139func actionName(n *node) string {
140 if n.uses.Kind == action.UsesLocal {
141 return n.uses.Path // no name: HandlerFactory.cs:200-208 uses the path alone
142 }
143 return registeredRef(n.uses)
144}
145
146// externalsDir is where the node versions live.
147func (j *job) externalsDir() string {
148 if d := j.cfg.Actions.ExternalsDir; d != "" {
149 return d
150 }
151 return defaultExternalsDir
152}
153
154// runNode is NodeScriptActionHandler.RunAsync (NodeScriptActionHandler.cs:
155// 27-196) on the host of the steps (a job container is phase H): INPUT_
156// variables and the PATH of the previous steps in the environment, the
157// script of the stage, node from the externals, run in the workspace.
158func (j *job) runNode(s *stepRun, def *actionDef, in *inputSet, env *ordered, cancel <-chan struct{}) stepResult {
159 num := s.num
160 for _, k := range in.keys {
161 v, _ := in.Get(k)
162 env.Set(inputEnvName(k), v)
163 }
164 // The PATH of the previous steps is added by procEnv.
165 env.Remove("NODE_ICU_DATA") // https://github.com/actions/runner/issues/795
166 r := def.runs()
167 var target string
168 switch s.stage {
169 case stageMain:
170 target = r.Main
171 case stagePre:
172 target = r.Pre
173 case stagePost:
174 target = r.Post
175 }
176 fail := func(err error) stepResult { j.errorf(num, "%s", err.Error()); return resFailed }
177 if target == "" {
178 return fail(ghErrorf("Value cannot be null or empty. (Parameter 'target')"))
179 }
180 script := pathCombine(def.dir, filepath.FromSlash(target))
181 if st, err := os.Stat(script); err != nil || st.IsDir() {
182 return fail(ghErrorf("File not found: '%s'", script))
183 }
184 if c := j.stepContainer(); c != nil {
185 return j.runNodeInContainer(s, c, script, env, cancel)
186 }
187 j.mu.Lock()
188 wd := j.state.GitHub("workspace")
189 j.mu.Unlock()
190 node := filepath.Join(j.externalsDir(), s.nodeVersion, "bin", "node")
191 s.actEnv = env
192 return j.runProc(s, node, wd, []string{node, script}, "Node Action run completed with exit code %d", cancel)
193}