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// addRuntimeEnv is the "Add Actions Runtime server info" part of the handlers
155// (NodeScriptActionHandler.cs:55-84, ContainerActionHandler.cs:219-245): the
156// variables @actions/cache and @actions/artifact read. ermite's runtime service
157// is the channel of the runner behind one URL, which serves the cache, the
158// results service (artifacts) or both: ACTIONS_RUNTIME_URL and
159// ACTIONS_RUNTIME_TOKEN, then, when the channel serves the cache,
160// ACTIONS_CACHE_URL (the base of "_apis/artifactcache/…": the toolkit appends
161// to it, cacheHttpClient.ts:47, so it ends with a slash) and ACTIONS_CACHE_MODE,
162// and, when it serves results, ACTIONS_RESULTS_URL (the handlers set it from
163// ResultsServiceUrl, NodeScriptActionHandler.cs:71-84, ContainerActionHandler.cs:
164// 237-245; the toolkit keeps only its origin, artifact shared/config.ts).
165// ACTIONS_CACHE_SERVICE_V2 is never set: the toolkit would then ignore
166// ACTIONS_CACHE_URL (config.ts:42-58) and, on a server URL that is not
167// github.com, uses v1 anyway (config.ts:22-27).
168//
169// inContainer says the process runs in a container (a step in the job
170// container, a docker action): it has its own network namespace and reaches
171// the relay at ContainerCacheURL, not on the loopback. Without a URL for where
172// the step runs, or without a token, nothing is set.
173func (j *job) addRuntimeEnv(env *ordered, inContainer bool) {
174 url := j.cfg.CacheURL
175 if inContainer {
176 url = j.cfg.ContainerCacheURL
177 }
178 if url == "" || j.cfg.CacheToken == "" {
179 return
180 }
181 env.Set("ACTIONS_RUNTIME_URL", url)
182 env.Set("ACTIONS_RUNTIME_TOKEN", j.cfg.CacheToken)
183 if !j.cfg.CacheOff {
184 env.Set("ACTIONS_CACHE_URL", url)
185 // ACTIONS_CACHE_MODE: NodeScriptActionHandler.cs:71-84, ContainerActionHandler.cs:
186 // 237-245; the toolkit skips uploads in "read" mode (cache config.ts:27-40).
187 if j.cfg.CacheMode != "" {
188 env.Set("ACTIONS_CACHE_MODE", j.cfg.CacheMode)
189 }
190 }
191 if j.cfg.Results {
192 env.Set("ACTIONS_RESULTS_URL", url)
193 }
194}
195
196// runNode is NodeScriptActionHandler.RunAsync (NodeScriptActionHandler.cs:
197// 27-196) on the host of the steps (a job container is phase H): INPUT_
198// variables and the PATH of the previous steps in the environment, the
199// script of the stage, node from the externals, run in the workspace.
200func (j *job) runNode(s *stepRun, def *actionDef, in *inputSet, env *ordered, cancel <-chan struct{}) stepResult {
201 num := s.num
202 for _, k := range in.keys {
203 v, _ := in.Get(k)
204 env.Set(inputEnvName(k), v)
205 }
206 j.addRuntimeEnv(env, j.stepContainer() != nil)
207 // The PATH of the previous steps is added by procEnv.
208 env.Remove("NODE_ICU_DATA") // https://github.com/actions/runner/issues/795
209 r := def.runs()
210 var target string
211 switch s.stage {
212 case stageMain:
213 target = r.Main
214 case stagePre:
215 target = r.Pre
216 case stagePost:
217 target = r.Post
218 }
219 fail := func(err error) stepResult { j.errorf(num, "%s", err.Error()); return resFailed }
220 if target == "" {
221 return fail(ghErrorf("Value cannot be null or empty. (Parameter 'target')"))
222 }
223 script := pathCombine(def.dir, filepath.FromSlash(target))
224 if st, err := os.Stat(script); err != nil || st.IsDir() {
225 return fail(ghErrorf("File not found: '%s'", script))
226 }
227 j.setArtifactsServerURL(s, script)
228 if c := j.stepContainer(); c != nil {
229 return j.runNodeInContainer(s, c, script, env, cancel)
230 }
231 j.mu.Lock()
232 wd := j.state.GitHub("workspace")
233 j.mu.Unlock()
234 node := filepath.Join(j.externalsDir(), s.nodeVersion, "bin", "node")
235 s.actEnv = env
236 return j.runProc(s, node, wd, []string{node, script}, "Node Action run completed with exit code %d", cancel)
237}