detached aea5d73

feat(executor): run node, docker, composite and local actions with pre and post stages

commit
aea5d73c1389fd7c06345fc69b0cd704b40ac557
author
brice · · 2026-10-13 16:30 UTC
parent
a6671fa
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
22 files · +2749 -267
A internal/executor/actions_composite.go +183 -0
@@ -0,0 +1,183 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "bnema.dev/ermite-actions/expr"
7+)
8+
9+// compositeGitHubKeys are the keys of the github context that a composite
10+// action's steps change: the runner gives them a copy of the context
11+// (CompositeActionHandler.cs:152-161), the executor restores these afterwards.
12+func compositeGitHubKeys() []string {
13+ keys := []string{"action_path", "action_status", "action_repository", "action_ref", "event_path"}
14+ for _, c := range fileCommands {
15+ keys = append(keys, c.ContextName)
16+ }
17+ return keys
18+}
19+
20+// prepareLocalComposite is the first part of ActionRunner.RunAsync for a local
21+// composite action (ActionRunner.cs:77-96): its steps are prepared when the
22+// step runs, in the log of the step, and the pulls and builds they need run
23+// first as embedded steps.
24+func (j *job) prepareLocalComposite(s *stepRun, def *actionDef) error {
25+ n := s.n
26+ if n.children != nil {
27+ return nil // pre and post stages reuse the nodes of the main stage
28+ }
29+ kids, err := newNodes(def.act.Runs.Steps, n, n.scope)
30+ if err != nil {
31+ return err
32+ }
33+ n.children = kids
34+ n.preSteps, n.postSteps = nil, nil
35+ j.out(s.num, "Prepare all required actions")
36+ if j.actions == nil {
37+ j.actions = newActionState()
38+ }
39+ saved := *j.actions
40+ j.actions.pullOrder, j.actions.buildOrder = nil, nil
41+ j.actions.pullUsers, j.actions.buildUsers = map[string][]*node{}, map[string][]*node{}
42+ if err := j.prepare(j.ctx(), s.num, kids, 1, n); err != nil {
43+ return err
44+ }
45+ setups := j.setupSteps()
46+ j.actions.pullOrder, j.actions.buildOrder = saved.pullOrder, saved.buildOrder
47+ j.actions.pullUsers, j.actions.buildUsers = saved.pullUsers, saved.buildUsers
48+ for _, q := range setups {
49+ if r := q.setup(s, nil); r != resSucceeded {
50+ return errSetupFailed
51+ }
52+ }
53+ return nil
54+}
55+
56+// errSetupFailed ends a composite whose image setup failed; the cause is in the
57+// log already.
58+var errSetupFailed = ghErrorf("A container setup step of the composite action failed.")
59+
60+// runComposite is CompositeActionHandler.RunAsync (CompositeActionHandler.cs:
61+// 30-207) and RunStepsAsync: the steps of the stage run in their own steps
62+// context with the inputs of the action, and the outputs are mapped from them.
63+func (j *job) runComposite(s *stepRun, def *actionDef, in *inputSet, env *ordered) stepResult {
64+ n := s.n
65+ var kids []*node
66+ switch s.stage {
67+ case stagePre:
68+ kids = n.preSteps
69+ case stagePost:
70+ // Only the steps that registered a post, last registered first.
71+ for i := len(n.postSteps) - 1; i >= 0; i-- {
72+ if c := n.postSteps[i]; c.postRegistered {
73+ kids = append(kids, c)
74+ }
75+ }
76+ default:
77+ kids = n.children
78+ }
79+ inputs := expr.NewDictionary()
80+ for _, k := range in.keys {
81+ v, _ := in.Get(k)
82+ inputs.Add(k, expr.String(v))
83+ }
84+ // The pre stage has no context name in the runner, so its steps context is a
85+ // random one; post shares the scope of main (siblingScopeName).
86+ scope := n.scope
87+ if s.stage == stagePre {
88+ scope = "__pre_" + n.scope
89+ }
90+ j.mu.Lock()
91+ restoreGH := j.state.GitHubKeys(compositeGitHubKeys()...)
92+ restoreScope := j.state.PushScope(scope, inputs)
93+ j.state.SetGitHub("action_path", def.dir)
94+ j.mu.Unlock()
95+ restored := false
96+ restore := func() {
97+ if !restored {
98+ restored = true
99+ restoreScope()
100+ restoreGH()
101+ }
102+ }
103+ defer func() {
104+ j.mu.Lock()
105+ restore()
106+ j.mu.Unlock()
107+ }()
108+ ok := resSucceeded
109+ s.result = &ok
110+ for _, c := range kids {
111+ j.runEmbedded(s, c)
112+ }
113+ // Outputs are evaluated in the steps context of the action and belong to the
114+ // step that uses it (ExecutionContext.SetOutput of the composite step).
115+ var outs []Pair
116+ if s.stage == stageMain {
117+ outs = j.compositeOutputs(s, def)
118+ }
119+ j.mu.Lock()
120+ restore()
121+ for _, o := range outs {
122+ j.state.SetOutput(s.st.ID, o.Key, o.Value)
123+ }
124+ j.mu.Unlock()
125+ return *s.result
126+}
127+
128+// compositeOutputs is ProcessOutputs (CompositeActionHandler.cs:192-228): the
129+// "value" of each output of the action, evaluated with the steps and inputs
130+// of the action.
131+func (j *job) compositeOutputs(s *stepRun, def *actionDef) []Pair {
132+ var out []Pair
133+ j.mu.Lock()
134+ defer j.mu.Unlock()
135+ for _, o := range def.act.Outputs {
136+ if o.Value == nil {
137+ continue
138+ }
139+ v, err := j.evalString(o.Value, s.env)
140+ if err != nil {
141+ j.mu.Unlock()
142+ j.errorf(s.num, "%s", templateNotValid(err, true))
143+ j.mu.Lock()
144+ m := resFailed
145+ s.result = &m
146+ return nil
147+ }
148+ out = append(out, Pair{o.Name, v})
149+ }
150+ return out
151+}
152+
153+// runEmbedded is one iteration of CompositeActionHandler.RunStepsAsync: the
154+// step of the composite action runs inside the step that uses it, with its own
155+// environment, condition, cancellation watch and result. Its log lines belong
156+// to the outer step.
157+func (j *job) runEmbedded(parent *stepRun, c *node) {
158+ s := &stepRun{j: j, num: parent.num, st: c.st, n: c, stage: parent.stage, parent: parent, fc: j.fc.Fork()}
159+ defer s.fc.Cleanup()
160+ s.cmds = newCommandManager(s, j.cfg.Debug)
161+ j.mu.Lock()
162+ // github.action_status: the result of the composite action so far.
163+ j.state.SetGitHub("action_status", parent.actionStatus())
164+ j.mu.Unlock()
165+ env, envErr := j.stepEnvironment(s)
166+ s.env = env
167+ s.displayName = j.displayName(c.st, env)
168+ if envErr != nil {
169+ j.errorf(s.num, "%s", templateNotValid(envErr, true))
170+ j.finishStep(s, resFailed)
171+ return
172+ }
173+ q := qstep{n: c, stage: parent.stage}
174+ switch parent.stage {
175+ case stagePre:
176+ if c.def != nil {
177+ q.cond = c.def.preIf()
178+ }
179+ case stagePost:
180+ q.cond = c.postCond
181+ }
182+ j.runGuarded(s, q, true)
183+}
@@ -0,0 +1,183 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "bnema.dev/ermite-actions/expr"
7+)
8+
9+// compositeGitHubKeys are the keys of the github context that a composite
10+// action's steps change: the runner gives them a copy of the context
11+// (CompositeActionHandler.cs:152-161), the executor restores these afterwards.
12+func compositeGitHubKeys() []string {
13+ keys := []string{"action_path", "action_status", "action_repository", "action_ref", "event_path"}
14+ for _, c := range fileCommands {
15+ keys = append(keys, c.ContextName)
16+ }
17+ return keys
18+}
19+
20+// prepareLocalComposite is the first part of ActionRunner.RunAsync for a local
21+// composite action (ActionRunner.cs:77-96): its steps are prepared when the
22+// step runs, in the log of the step, and the pulls and builds they need run
23+// first as embedded steps.
24+func (j *job) prepareLocalComposite(s *stepRun, def *actionDef) error {
25+ n := s.n
26+ if n.children != nil {
27+ return nil // pre and post stages reuse the nodes of the main stage
28+ }
29+ kids, err := newNodes(def.act.Runs.Steps, n, n.scope)
30+ if err != nil {
31+ return err
32+ }
33+ n.children = kids
34+ n.preSteps, n.postSteps = nil, nil
35+ j.out(s.num, "Prepare all required actions")
36+ if j.actions == nil {
37+ j.actions = newActionState()
38+ }
39+ saved := *j.actions
40+ j.actions.pullOrder, j.actions.buildOrder = nil, nil
41+ j.actions.pullUsers, j.actions.buildUsers = map[string][]*node{}, map[string][]*node{}
42+ if err := j.prepare(j.ctx(), s.num, kids, 1, n); err != nil {
43+ return err
44+ }
45+ setups := j.setupSteps()
46+ j.actions.pullOrder, j.actions.buildOrder = saved.pullOrder, saved.buildOrder
47+ j.actions.pullUsers, j.actions.buildUsers = saved.pullUsers, saved.buildUsers
48+ for _, q := range setups {
49+ if r := q.setup(s, nil); r != resSucceeded {
50+ return errSetupFailed
51+ }
52+ }
53+ return nil
54+}
55+
56+// errSetupFailed ends a composite whose image setup failed; the cause is in the
57+// log already.
58+var errSetupFailed = ghErrorf("A container setup step of the composite action failed.")
59+
60+// runComposite is CompositeActionHandler.RunAsync (CompositeActionHandler.cs:
61+// 30-207) and RunStepsAsync: the steps of the stage run in their own steps
62+// context with the inputs of the action, and the outputs are mapped from them.
63+func (j *job) runComposite(s *stepRun, def *actionDef, in *inputSet, env *ordered) stepResult {
64+ n := s.n
65+ var kids []*node
66+ switch s.stage {
67+ case stagePre:
68+ kids = n.preSteps
69+ case stagePost:
70+ // Only the steps that registered a post, last registered first.
71+ for i := len(n.postSteps) - 1; i >= 0; i-- {
72+ if c := n.postSteps[i]; c.postRegistered {
73+ kids = append(kids, c)
74+ }
75+ }
76+ default:
77+ kids = n.children
78+ }
79+ inputs := expr.NewDictionary()
80+ for _, k := range in.keys {
81+ v, _ := in.Get(k)
82+ inputs.Add(k, expr.String(v))
83+ }
84+ // The pre stage has no context name in the runner, so its steps context is a
85+ // random one; post shares the scope of main (siblingScopeName).
86+ scope := n.scope
87+ if s.stage == stagePre {
88+ scope = "__pre_" + n.scope
89+ }
90+ j.mu.Lock()
91+ restoreGH := j.state.GitHubKeys(compositeGitHubKeys()...)
92+ restoreScope := j.state.PushScope(scope, inputs)
93+ j.state.SetGitHub("action_path", def.dir)
94+ j.mu.Unlock()
95+ restored := false
96+ restore := func() {
97+ if !restored {
98+ restored = true
99+ restoreScope()
100+ restoreGH()
101+ }
102+ }
103+ defer func() {
104+ j.mu.Lock()
105+ restore()
106+ j.mu.Unlock()
107+ }()
108+ ok := resSucceeded
109+ s.result = &ok
110+ for _, c := range kids {
111+ j.runEmbedded(s, c)
112+ }
113+ // Outputs are evaluated in the steps context of the action and belong to the
114+ // step that uses it (ExecutionContext.SetOutput of the composite step).
115+ var outs []Pair
116+ if s.stage == stageMain {
117+ outs = j.compositeOutputs(s, def)
118+ }
119+ j.mu.Lock()
120+ restore()
121+ for _, o := range outs {
122+ j.state.SetOutput(s.st.ID, o.Key, o.Value)
123+ }
124+ j.mu.Unlock()
125+ return *s.result
126+}
127+
128+// compositeOutputs is ProcessOutputs (CompositeActionHandler.cs:192-228): the
129+// "value" of each output of the action, evaluated with the steps and inputs
130+// of the action.
131+func (j *job) compositeOutputs(s *stepRun, def *actionDef) []Pair {
132+ var out []Pair
133+ j.mu.Lock()
134+ defer j.mu.Unlock()
135+ for _, o := range def.act.Outputs {
136+ if o.Value == nil {
137+ continue
138+ }
139+ v, err := j.evalString(o.Value, s.env)
140+ if err != nil {
141+ j.mu.Unlock()
142+ j.errorf(s.num, "%s", templateNotValid(err, true))
143+ j.mu.Lock()
144+ m := resFailed
145+ s.result = &m
146+ return nil
147+ }
148+ out = append(out, Pair{o.Name, v})
149+ }
150+ return out
151+}
152+
153+// runEmbedded is one iteration of CompositeActionHandler.RunStepsAsync: the
154+// step of the composite action runs inside the step that uses it, with its own
155+// environment, condition, cancellation watch and result. Its log lines belong
156+// to the outer step.
157+func (j *job) runEmbedded(parent *stepRun, c *node) {
158+ s := &stepRun{j: j, num: parent.num, st: c.st, n: c, stage: parent.stage, parent: parent, fc: j.fc.Fork()}
159+ defer s.fc.Cleanup()
160+ s.cmds = newCommandManager(s, j.cfg.Debug)
161+ j.mu.Lock()
162+ // github.action_status: the result of the composite action so far.
163+ j.state.SetGitHub("action_status", parent.actionStatus())
164+ j.mu.Unlock()
165+ env, envErr := j.stepEnvironment(s)
166+ s.env = env
167+ s.displayName = j.displayName(c.st, env)
168+ if envErr != nil {
169+ j.errorf(s.num, "%s", templateNotValid(envErr, true))
170+ j.finishStep(s, resFailed)
171+ return
172+ }
173+ q := qstep{n: c, stage: parent.stage}
174+ switch parent.stage {
175+ case stagePre:
176+ if c.def != nil {
177+ q.cond = c.def.preIf()
178+ }
179+ case stagePost:
180+ q.cond = c.postCond
181+ }
182+ j.runGuarded(s, q, true)
183+}
A internal/executor/actions_docker.go +272 -0
@@ -0,0 +1,272 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "context"
7+ "fmt"
8+ "os"
9+ "path/filepath"
10+ "strings"
11+
12+ "bnema.dev/ermite-actions/action"
13+ "bnema.dev/ermite-actions/expr"
14+ "bnema.dev/ermite-actions/template"
15+)
16+
17+// pathMap is ContainerInfo's path translation for a docker action: the host
18+// directories mounted below /github. The runner inserts each mapping first, so
19+// the last added is tried first (ContainerInfo.AddPathTranslateMapping).
20+type pathMap struct{ host, container string }
21+
22+func translatePath(maps []pathMap, p string) string {
23+ if p == "" {
24+ return p
25+ }
26+ for _, m := range maps {
27+ if p == m.host {
28+ return m.container
29+ }
30+ if strings.HasPrefix(p, m.host+"/") {
31+ return m.container + p[len(m.host):]
32+ }
33+ }
34+ return p
35+}
36+
37+// ctx is the context of the job's own operations (downloads, resolutions): it
38+// ends when the job is cancelled.
39+func (j *job) ctx() context.Context {
40+ if j.jobCtx == nil {
41+ return context.Background()
42+ }
43+ return j.jobCtx
44+}
45+
46+// dockerEnvContext evaluates against the contexts of the step with the inputs
47+// context replaced: the arguments and the env of a container action see its
48+// inputs, with the defaults (ContainerActionHandler.cs:122-131).
49+func (j *job) dockerEnvContext(env *ordered, in *inputSet) template.Env {
50+ te := j.evalEnv(env)
51+ ctxs := make(map[string]expr.Value, len(te.Contexts)+1)
52+ for k, v := range te.Contexts {
53+ ctxs[k] = v
54+ }
55+ d := expr.NewDictionary()
56+ for _, k := range in.keys {
57+ v, _ := in.Get(k)
58+ d.Add(k, expr.String(v))
59+ }
60+ ctxs["inputs"] = d
61+ te.Contexts = ctxs
62+ return te
63+}
64+
65+// quoteArg is the quoting of one argument of the entrypoint
66+// (ContainerActionHandler.cs:139-148): in double quotes, a quote preceded by a
67+// backslash; an empty argument is "".
68+func quoteArg(a string) string {
69+ if a == "" {
70+ return `""`
71+ }
72+ return `"` + strings.ReplaceAll(a, `"`, `\"`) + `"`
73+}
74+
75+// dockerBuildCmd is docker build (DockerCommandManager.DockerBuild).
76+func (j *job) dockerBuildCmd(tag, dockerfile, context string) dockerCmd {
77+ return j.newDockerCmd("build",
78+ dockerPart{line: "-t " + tag, args: []string{"-t", tag}},
79+ dockerPart{line: `-f "` + dockerfile + `"`, args: []string{"-f", dockerfile}},
80+ dockerPart{line: `"` + context + `"`, args: []string{context}})
81+}
82+
83+// buildImage builds an image from a Dockerfile, in the step.
84+func (j *job) buildImage(s *stepRun, tag, dockerfile, workDir string, cancel <-chan struct{}) error {
85+ c := j.dockerBuildCmd(tag, dockerfile, filepath.Dir(dockerfile))
86+ code, err := j.runDockerCmd(s, c, dirOrCwd(workDir), nil, cancel)
87+ if err != nil {
88+ return err
89+ }
90+ if code != 0 {
91+ return fmt.Errorf("Docker build failed with exit code %d", code)
92+ }
93+ return nil
94+}
95+
96+// runDocker is ContainerActionHandler.RunAsync (ContainerActionHandler.cs:
97+// 34-277) in a sandbox that has the docker CLI: the same mounts, variables and
98+// options as the runner's docker run.
99+func (j *job) runDocker(s *stepRun, def *actionDef, in *inputSet, env *ordered, cancel <-chan struct{}) stepResult {
100+ num, n := s.num, s.n
101+ fail := func(err error) stepResult { j.errorf(num, "%s", err.Error()); return resFailed }
102+ for _, k := range in.keys {
103+ v, _ := in.Get(k)
104+ env.Set(inputEnvName(k), v)
105+ }
106+ j.mu.Lock()
107+ workspace := j.state.GitHub("workspace")
108+ temp := j.state.Layout().Temp
109+ j.mu.Unlock()
110+ image := n.image
111+ if image == "" {
112+ var build string
113+ if def.act == nil && def.dockerfile != "" {
114+ build = def.dockerfile
115+ } else if def.act == nil {
116+ image = def.image
117+ } else {
118+ im, err := action.ParseImage(def.image)
119+ if err != nil {
120+ return fail(err)
121+ }
122+ if im.Kind == action.ImageRegistry {
123+ image = im.Ref
124+ } else {
125+ build = filepath.Join(def.dir, filepath.FromSlash(im.Path))
126+ }
127+ }
128+ if build != "" {
129+ if _, err := os.Stat(build); err != nil {
130+ return fail(fmt.Errorf("File not found: '%s'", build))
131+ }
132+ j.out(num, "##[group]Building docker image")
133+ j.out(num, fmt.Sprintf("Dockerfile for action: '%s'.", build))
134+ image = newImageTag(j.dockerLabel())
135+ err := j.buildImage(s, image, build, workspace, cancel)
136+ j.out(num, "##[endgroup]")
137+ if err != nil {
138+ if err == errCancelled {
139+ return j.cancelledStep(num)
140+ }
141+ return fail(err)
142+ }
143+ }
144+ }
145+ var entry string
146+ r := def.runs()
147+ switch s.stage {
148+ case stageMain:
149+ if r != nil && r.Entrypoint != "" {
150+ entry = r.Entrypoint
151+ } else if v, ok := in.Get("entryPoint"); ok {
152+ entry = v
153+ }
154+ case stagePre:
155+ if r != nil {
156+ entry = r.PreEntrypoint
157+ }
158+ case stagePost:
159+ if r != nil {
160+ entry = r.PostEntrypoint
161+ }
162+ }
163+ te := j.dockerEnvContext(env, in)
164+ argLine, argList, err := j.dockerArgs(r, in, te)
165+ if err != nil {
166+ j.errorf(num, "%s", templateNotValid(err, true))
167+ return resFailed
168+ }
169+ if r != nil && r.Env != nil {
170+ v, err := template.Evaluate(context.Background(), r.Env, te)
171+ if err != nil {
172+ j.errorf(num, "%s", templateNotValid(err, true))
173+ return resFailed
174+ }
175+ if d, ok := v.(*expr.Dictionary); ok {
176+ for _, p := range d.Pairs() {
177+ if _, has := env.Get(p.Key); !has {
178+ env.Set(p.Key, expr.CoerceString(p.Value))
179+ }
180+ }
181+ }
182+ }
183+ // The directories the container sees (ContainerActionHandler.cs:171-198).
184+ home := filepath.Join(temp, "_github_home")
185+ workflowDir := filepath.Join(temp, "_github_workflow")
186+ fcDir := filepath.Join(temp, "_runner_file_commands")
187+ for _, d := range []string{home, workflowDir} {
188+ if err := os.MkdirAll(d, 0o755); err != nil {
189+ return fail(err)
190+ }
191+ j.own(d)
192+ }
193+ env.Set("HOME", home)
194+ maps := []pathMap{ // last added first
195+ {workspace, "/github/workspace"}, {fcDir, "/github/file_commands"}, {workflowDir, "/github/workflow"},
196+ {home, "/github/home"}, {temp, "/github/runner_temp"},
197+ }
198+ // The runtime variables of the contexts (GITHUB_*, RUNNER_*).
199+ j.mu.Lock()
200+ runtime := j.state.RuntimeEnv()
201+ j.mu.Unlock()
202+ for _, k := range runtime.Keys() {
203+ v, _ := runtime.Get(k)
204+ env.Set(k, v)
205+ }
206+ cenv := newOrdered()
207+ for _, k := range env.Keys() {
208+ v, _ := env.Get(k)
209+ cenv.Set(k, translatePath(maps, v))
210+ }
211+ parts := []dockerPart{
212+ raw("--name", sanitizeName(image)+"_"+shortID()),
213+ raw("--label", j.dockerLabel()),
214+ raw("--workdir", "/github/workspace"),
215+ raw("--rm"),
216+ }
217+ for _, k := range cenv.Keys() {
218+ parts = append(parts, envOpt(k))
219+ }
220+ parts = append(parts, raw("-e", "GITHUB_ACTIONS=true"))
221+ if _, ok := cenv.Get("CI"); !ok {
222+ parts = append(parts, raw("-e", "CI=true"))
223+ }
224+ if entry != "" {
225+ parts = append(parts, entrypointOpt(entry))
226+ }
227+ parts = append(parts,
228+ volumeOpt("/var/run/docker.sock", "/var/run/docker.sock"),
229+ volumeOpt(temp, "/github/runner_temp"),
230+ volumeOpt(home, "/github/home"),
231+ volumeOpt(workflowDir, "/github/workflow"),
232+ volumeOpt(fcDir, "/github/file_commands"),
233+ volumeOpt(workspace, "/github/workspace"),
234+ raw(image),
235+ dockerPart{line: argLine, args: argList})
236+ cmd := j.newDockerCmd("run", parts...)
237+ j.out(num, "##[command]"+cmd.line)
238+ // The docker process gets the container variables on top of its own
239+ // environment: "-e NAME" reads them from there.
240+ s.rawEnvList = j.processEnv(cenv)
241+ return j.runProc(s, cmd.argv[0], workspace, cmd.argv, "Docker Action run completed with exit code %d", cancel)
242+}
243+
244+// dockerArgs is the "args" of the entrypoint: the manifest's list, evaluated and
245+// quoted, or the "args" input as it is (ContainerActionHandler.cs:133-161).
246+func (j *job) dockerArgs(r *action.Runs, in *inputSet, te template.Env) (line string, list []string, err error) {
247+ if r != nil && r.Args != nil {
248+ v, err := template.Evaluate(context.Background(), r.Args, te)
249+ if err != nil {
250+ return "", nil, err
251+ }
252+ a, _ := v.(*expr.Array)
253+ if a != nil {
254+ for _, it := range a.Items() {
255+ s := expr.CoerceString(it)
256+ line += " " + quoteArg(s)
257+ list = append(list, s)
258+ }
259+ }
260+ return line, list, nil
261+ }
262+ raw, _ := in.Get("args")
263+ return raw, splitArgs(raw), nil
264+}
265+
266+// cancelledStep logs the cancellation of a step that was running docker.
267+func (j *job) cancelledStep(num int) stepResult {
268+ j.errorf(num, "The operation was canceled.")
269+ return resCanceled
270+}
271+
272+func shortID() string { return strings.ReplaceAll(newImageTag(""), ":", "")[:6] }
@@ -0,0 +1,272 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "context"
7+ "fmt"
8+ "os"
9+ "path/filepath"
10+ "strings"
11+
12+ "bnema.dev/ermite-actions/action"
13+ "bnema.dev/ermite-actions/expr"
14+ "bnema.dev/ermite-actions/template"
15+)
16+
17+// pathMap is ContainerInfo's path translation for a docker action: the host
18+// directories mounted below /github. The runner inserts each mapping first, so
19+// the last added is tried first (ContainerInfo.AddPathTranslateMapping).
20+type pathMap struct{ host, container string }
21+
22+func translatePath(maps []pathMap, p string) string {
23+ if p == "" {
24+ return p
25+ }
26+ for _, m := range maps {
27+ if p == m.host {
28+ return m.container
29+ }
30+ if strings.HasPrefix(p, m.host+"/") {
31+ return m.container + p[len(m.host):]
32+ }
33+ }
34+ return p
35+}
36+
37+// ctx is the context of the job's own operations (downloads, resolutions): it
38+// ends when the job is cancelled.
39+func (j *job) ctx() context.Context {
40+ if j.jobCtx == nil {
41+ return context.Background()
42+ }
43+ return j.jobCtx
44+}
45+
46+// dockerEnvContext evaluates against the contexts of the step with the inputs
47+// context replaced: the arguments and the env of a container action see its
48+// inputs, with the defaults (ContainerActionHandler.cs:122-131).
49+func (j *job) dockerEnvContext(env *ordered, in *inputSet) template.Env {
50+ te := j.evalEnv(env)
51+ ctxs := make(map[string]expr.Value, len(te.Contexts)+1)
52+ for k, v := range te.Contexts {
53+ ctxs[k] = v
54+ }
55+ d := expr.NewDictionary()
56+ for _, k := range in.keys {
57+ v, _ := in.Get(k)
58+ d.Add(k, expr.String(v))
59+ }
60+ ctxs["inputs"] = d
61+ te.Contexts = ctxs
62+ return te
63+}
64+
65+// quoteArg is the quoting of one argument of the entrypoint
66+// (ContainerActionHandler.cs:139-148): in double quotes, a quote preceded by a
67+// backslash; an empty argument is "".
68+func quoteArg(a string) string {
69+ if a == "" {
70+ return `""`
71+ }
72+ return `"` + strings.ReplaceAll(a, `"`, `\"`) + `"`
73+}
74+
75+// dockerBuildCmd is docker build (DockerCommandManager.DockerBuild).
76+func (j *job) dockerBuildCmd(tag, dockerfile, context string) dockerCmd {
77+ return j.newDockerCmd("build",
78+ dockerPart{line: "-t " + tag, args: []string{"-t", tag}},
79+ dockerPart{line: `-f "` + dockerfile + `"`, args: []string{"-f", dockerfile}},
80+ dockerPart{line: `"` + context + `"`, args: []string{context}})
81+}
82+
83+// buildImage builds an image from a Dockerfile, in the step.
84+func (j *job) buildImage(s *stepRun, tag, dockerfile, workDir string, cancel <-chan struct{}) error {
85+ c := j.dockerBuildCmd(tag, dockerfile, filepath.Dir(dockerfile))
86+ code, err := j.runDockerCmd(s, c, dirOrCwd(workDir), nil, cancel)
87+ if err != nil {
88+ return err
89+ }
90+ if code != 0 {
91+ return fmt.Errorf("Docker build failed with exit code %d", code)
92+ }
93+ return nil
94+}
95+
96+// runDocker is ContainerActionHandler.RunAsync (ContainerActionHandler.cs:
97+// 34-277) in a sandbox that has the docker CLI: the same mounts, variables and
98+// options as the runner's docker run.
99+func (j *job) runDocker(s *stepRun, def *actionDef, in *inputSet, env *ordered, cancel <-chan struct{}) stepResult {
100+ num, n := s.num, s.n
101+ fail := func(err error) stepResult { j.errorf(num, "%s", err.Error()); return resFailed }
102+ for _, k := range in.keys {
103+ v, _ := in.Get(k)
104+ env.Set(inputEnvName(k), v)
105+ }
106+ j.mu.Lock()
107+ workspace := j.state.GitHub("workspace")
108+ temp := j.state.Layout().Temp
109+ j.mu.Unlock()
110+ image := n.image
111+ if image == "" {
112+ var build string
113+ if def.act == nil && def.dockerfile != "" {
114+ build = def.dockerfile
115+ } else if def.act == nil {
116+ image = def.image
117+ } else {
118+ im, err := action.ParseImage(def.image)
119+ if err != nil {
120+ return fail(err)
121+ }
122+ if im.Kind == action.ImageRegistry {
123+ image = im.Ref
124+ } else {
125+ build = filepath.Join(def.dir, filepath.FromSlash(im.Path))
126+ }
127+ }
128+ if build != "" {
129+ if _, err := os.Stat(build); err != nil {
130+ return fail(fmt.Errorf("File not found: '%s'", build))
131+ }
132+ j.out(num, "##[group]Building docker image")
133+ j.out(num, fmt.Sprintf("Dockerfile for action: '%s'.", build))
134+ image = newImageTag(j.dockerLabel())
135+ err := j.buildImage(s, image, build, workspace, cancel)
136+ j.out(num, "##[endgroup]")
137+ if err != nil {
138+ if err == errCancelled {
139+ return j.cancelledStep(num)
140+ }
141+ return fail(err)
142+ }
143+ }
144+ }
145+ var entry string
146+ r := def.runs()
147+ switch s.stage {
148+ case stageMain:
149+ if r != nil && r.Entrypoint != "" {
150+ entry = r.Entrypoint
151+ } else if v, ok := in.Get("entryPoint"); ok {
152+ entry = v
153+ }
154+ case stagePre:
155+ if r != nil {
156+ entry = r.PreEntrypoint
157+ }
158+ case stagePost:
159+ if r != nil {
160+ entry = r.PostEntrypoint
161+ }
162+ }
163+ te := j.dockerEnvContext(env, in)
164+ argLine, argList, err := j.dockerArgs(r, in, te)
165+ if err != nil {
166+ j.errorf(num, "%s", templateNotValid(err, true))
167+ return resFailed
168+ }
169+ if r != nil && r.Env != nil {
170+ v, err := template.Evaluate(context.Background(), r.Env, te)
171+ if err != nil {
172+ j.errorf(num, "%s", templateNotValid(err, true))
173+ return resFailed
174+ }
175+ if d, ok := v.(*expr.Dictionary); ok {
176+ for _, p := range d.Pairs() {
177+ if _, has := env.Get(p.Key); !has {
178+ env.Set(p.Key, expr.CoerceString(p.Value))
179+ }
180+ }
181+ }
182+ }
183+ // The directories the container sees (ContainerActionHandler.cs:171-198).
184+ home := filepath.Join(temp, "_github_home")
185+ workflowDir := filepath.Join(temp, "_github_workflow")
186+ fcDir := filepath.Join(temp, "_runner_file_commands")
187+ for _, d := range []string{home, workflowDir} {
188+ if err := os.MkdirAll(d, 0o755); err != nil {
189+ return fail(err)
190+ }
191+ j.own(d)
192+ }
193+ env.Set("HOME", home)
194+ maps := []pathMap{ // last added first
195+ {workspace, "/github/workspace"}, {fcDir, "/github/file_commands"}, {workflowDir, "/github/workflow"},
196+ {home, "/github/home"}, {temp, "/github/runner_temp"},
197+ }
198+ // The runtime variables of the contexts (GITHUB_*, RUNNER_*).
199+ j.mu.Lock()
200+ runtime := j.state.RuntimeEnv()
201+ j.mu.Unlock()
202+ for _, k := range runtime.Keys() {
203+ v, _ := runtime.Get(k)
204+ env.Set(k, v)
205+ }
206+ cenv := newOrdered()
207+ for _, k := range env.Keys() {
208+ v, _ := env.Get(k)
209+ cenv.Set(k, translatePath(maps, v))
210+ }
211+ parts := []dockerPart{
212+ raw("--name", sanitizeName(image)+"_"+shortID()),
213+ raw("--label", j.dockerLabel()),
214+ raw("--workdir", "/github/workspace"),
215+ raw("--rm"),
216+ }
217+ for _, k := range cenv.Keys() {
218+ parts = append(parts, envOpt(k))
219+ }
220+ parts = append(parts, raw("-e", "GITHUB_ACTIONS=true"))
221+ if _, ok := cenv.Get("CI"); !ok {
222+ parts = append(parts, raw("-e", "CI=true"))
223+ }
224+ if entry != "" {
225+ parts = append(parts, entrypointOpt(entry))
226+ }
227+ parts = append(parts,
228+ volumeOpt("/var/run/docker.sock", "/var/run/docker.sock"),
229+ volumeOpt(temp, "/github/runner_temp"),
230+ volumeOpt(home, "/github/home"),
231+ volumeOpt(workflowDir, "/github/workflow"),
232+ volumeOpt(fcDir, "/github/file_commands"),
233+ volumeOpt(workspace, "/github/workspace"),
234+ raw(image),
235+ dockerPart{line: argLine, args: argList})
236+ cmd := j.newDockerCmd("run", parts...)
237+ j.out(num, "##[command]"+cmd.line)
238+ // The docker process gets the container variables on top of its own
239+ // environment: "-e NAME" reads them from there.
240+ s.rawEnvList = j.processEnv(cenv)
241+ return j.runProc(s, cmd.argv[0], workspace, cmd.argv, "Docker Action run completed with exit code %d", cancel)
242+}
243+
244+// dockerArgs is the "args" of the entrypoint: the manifest's list, evaluated and
245+// quoted, or the "args" input as it is (ContainerActionHandler.cs:133-161).
246+func (j *job) dockerArgs(r *action.Runs, in *inputSet, te template.Env) (line string, list []string, err error) {
247+ if r != nil && r.Args != nil {
248+ v, err := template.Evaluate(context.Background(), r.Args, te)
249+ if err != nil {
250+ return "", nil, err
251+ }
252+ a, _ := v.(*expr.Array)
253+ if a != nil {
254+ for _, it := range a.Items() {
255+ s := expr.CoerceString(it)
256+ line += " " + quoteArg(s)
257+ list = append(list, s)
258+ }
259+ }
260+ return line, list, nil
261+ }
262+ raw, _ := in.Get("args")
263+ return raw, splitArgs(raw), nil
264+}
265+
266+// cancelledStep logs the cancellation of a step that was running docker.
267+func (j *job) cancelledStep(num int) stepResult {
268+ j.errorf(num, "The operation was canceled.")
269+ return resCanceled
270+}
271+
272+func shortID() string { return strings.ReplaceAll(newImageTag(""), ":", "")[:6] }
A internal/executor/actions_docker_cmd.go +198 -0
@@ -0,0 +1,198 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "crypto/sha256"
7+ "encoding/hex"
8+ "fmt"
9+ "os"
10+ "os/exec"
11+ "regexp"
12+ "strings"
13+
14+ "github.com/google/uuid"
15+)
16+
17+// dockerPath is the docker CLI (DockerCommandManager.Initialize: which docker).
18+func (j *job) dockerPath() string {
19+ if p := j.cfg.Actions.DockerPath; p != "" {
20+ return p
21+ }
22+ if p, err := exec.LookPath("docker"); err == nil {
23+ return p
24+ }
25+ return "/usr/bin/docker"
26+}
27+
28+// dockerLabel is DockerInstanceLabel: six hex characters that identify the
29+// runner (the first characters of the SHA-256 of its .runner file). The
30+// sandbox has one runner per job; the job's tracking id stands in.
31+func (j *job) dockerLabel() string {
32+ h := sha256.Sum256([]byte(j.trackID))
33+ return hex.EncodeToString(h[:])[:6]
34+}
35+
36+// escapeDockerArg is DockerUtil.EscapeString: the string wrapped in quotes with
37+// the backslashes before a quote doubled (and at the end), as the runner writes
38+// the arguments of its docker commands (it echoes them, so the log has them).
39+var (
40+ quoteEscape = regexp.MustCompile(`(\\*)"`)
41+ endOfStringEscape = regexp.MustCompile(`(\\+)$`)
42+)
43+
44+func escapeDockerArg(v string) string {
45+ if v == "" {
46+ return ""
47+ }
48+ v = quoteEscape.ReplaceAllString(v, `$1$1\"`)
49+ v = endOfStringEscape.ReplaceAllString(v, `$1$1`)
50+ return `"` + v + `"`
51+}
52+
53+// dockerCmd is a docker command: the line the runner echoes and the arguments
54+// that are run.
55+type dockerCmd struct {
56+ line string
57+ argv []string
58+}
59+
60+// newDockerCmd builds "docker <command> <options>" the way the runner does
61+// (ExecuteDockerCommandAsync: command and options joined by a space and
62+// trimmed) and keeps the argv apart, so that nothing is re-split.
63+func (j *job) newDockerCmd(command string, parts ...dockerPart) dockerCmd {
64+ lines := []string{}
65+ argv := []string{j.dockerPath(), command}
66+ for _, p := range parts {
67+ lines = append(lines, p.line)
68+ argv = append(argv, p.args...)
69+ }
70+ line := strings.TrimSpace(command + " " + strings.Join(lines, " "))
71+ return dockerCmd{line: j.dockerPath() + " " + line, argv: argv}
72+}
73+
74+// dockerPart is one piece of a docker command: its text in the echoed line and
75+// its arguments.
76+type dockerPart struct {
77+ line string
78+ args []string
79+}
80+
81+func raw(args ...string) dockerPart { return dockerPart{line: strings.Join(args, " "), args: args} }
82+
83+// envOpt is CreateEscapedOption("-e", key): the value is read from the
84+// environment of the docker process.
85+func envOpt(key string) dockerPart {
86+ return dockerPart{line: "-e " + escapeDockerArg(key), args: []string{"-e", key}}
87+}
88+
89+func entrypointOpt(ep string) dockerPart {
90+ return dockerPart{line: `--entrypoint "` + ep + `"`, args: []string{"--entrypoint", ep}}
91+}
92+
93+func volumeOpt(src, dst string) dockerPart {
94+ return dockerPart{
95+ line: `-v "` + strings.ReplaceAll(src, `"`, `\"`) + `":"` + strings.ReplaceAll(dst, `"`, `\"`) + `"`,
96+ args: []string{"-v", src + ":" + dst},
97+ }
98+}
99+
100+// sanitizeName is NameValidation.Sanitize: letters, digits (not first) and "_".
101+func sanitizeName(s string) string {
102+ var b strings.Builder
103+ for _, r := range s {
104+ switch {
105+ case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r == '_':
106+ b.WriteRune(r)
107+ case r >= '0' && r <= '9' && b.Len() > 0:
108+ b.WriteRune(r)
109+ }
110+ }
111+ return b.String()
112+}
113+
114+func newImageTag(label string) string {
115+ return label + ":" + strings.ReplaceAll(uuid.NewString(), "-", "")
116+}
117+
118+// splitArgs splits a command line like .NET does for the arguments of a
119+// process: spaces separate, double quotes group, a backslash before a quote
120+// escapes it (ArgUtil.SplitArguments is not needed: ProcessInvoker passes the
121+// string to the platform, which splits it with these rules).
122+func splitArgs(s string) []string {
123+ var out []string
124+ var cur strings.Builder
125+ in, started := false, false
126+ for i := 0; i < len(s); i++ {
127+ c := s[i]
128+ switch {
129+ case c == '\\':
130+ n := 0
131+ for i < len(s) && s[i] == '\\' {
132+ n++
133+ i++
134+ }
135+ if i < len(s) && s[i] == '"' {
136+ cur.WriteString(strings.Repeat(`\`, n/2))
137+ if n%2 == 1 {
138+ cur.WriteByte('"')
139+ } else {
140+ in = !in
141+ }
142+ } else {
143+ cur.WriteString(strings.Repeat(`\`, n))
144+ i--
145+ }
146+ started = true
147+ case c == '"':
148+ in = !in
149+ started = true
150+ case (c == ' ' || c == '\t') && !in:
151+ if started {
152+ out = append(out, cur.String())
153+ cur.Reset()
154+ started = false
155+ }
156+ default:
157+ cur.WriteByte(c)
158+ started = true
159+ }
160+ }
161+ if started {
162+ out = append(out, cur.String())
163+ }
164+ return out
165+}
166+
167+// runDockerCmd echoes the command (##[command], ExecutionContext.Command) and
168+// runs it, sending every line of its output to the log as is: the output of
169+// docker is not interpreted as workflow commands (ExecuteDockerCommandAsync
170+// writes it with context.Output). It returns the exit code.
171+func (j *job) runDockerCmd(s *stepRun, c dockerCmd, dir string, env []string, cancel <-chan struct{}) (int, error) {
172+ j.out(s.num, "##[command]"+c.line)
173+ if env == nil {
174+ env = j.processEnv(newOrdered())
175+ }
176+ res, err := runProcess(procSpec{
177+ Argv: c.argv, Dir: dir, Env: env, Credential: j.cfg.Credential,
178+ OnLine: func(line string) { j.write(s.num, line) },
179+ Cancel: cancel, TermAfter: j.cfg.TermAfter, KillAfter: j.cfg.KillAfter, WaitDelay: j.cfg.WaitDelay, Signal: j.cfg.Signal,
180+ })
181+ if err != nil {
182+ return 0, err
183+ }
184+ if res.Cancelled {
185+ return res.ExitCode, errCancelled
186+ }
187+ return res.ExitCode, nil
188+}
189+
190+// errCancelled is a docker command stopped by the cancellation of the step.
191+var errCancelled = fmt.Errorf("The operation was canceled.")
192+
193+func dirOrCwd(d string) string {
194+ if st, err := os.Stat(d); err == nil && st.IsDir() {
195+ return d
196+ }
197+ return ""
198+}
@@ -0,0 +1,198 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "crypto/sha256"
7+ "encoding/hex"
8+ "fmt"
9+ "os"
10+ "os/exec"
11+ "regexp"
12+ "strings"
13+
14+ "github.com/google/uuid"
15+)
16+
17+// dockerPath is the docker CLI (DockerCommandManager.Initialize: which docker).
18+func (j *job) dockerPath() string {
19+ if p := j.cfg.Actions.DockerPath; p != "" {
20+ return p
21+ }
22+ if p, err := exec.LookPath("docker"); err == nil {
23+ return p
24+ }
25+ return "/usr/bin/docker"
26+}
27+
28+// dockerLabel is DockerInstanceLabel: six hex characters that identify the
29+// runner (the first characters of the SHA-256 of its .runner file). The
30+// sandbox has one runner per job; the job's tracking id stands in.
31+func (j *job) dockerLabel() string {
32+ h := sha256.Sum256([]byte(j.trackID))
33+ return hex.EncodeToString(h[:])[:6]
34+}
35+
36+// escapeDockerArg is DockerUtil.EscapeString: the string wrapped in quotes with
37+// the backslashes before a quote doubled (and at the end), as the runner writes
38+// the arguments of its docker commands (it echoes them, so the log has them).
39+var (
40+ quoteEscape = regexp.MustCompile(`(\\*)"`)
41+ endOfStringEscape = regexp.MustCompile(`(\\+)$`)
42+)
43+
44+func escapeDockerArg(v string) string {
45+ if v == "" {
46+ return ""
47+ }
48+ v = quoteEscape.ReplaceAllString(v, `$1$1\"`)
49+ v = endOfStringEscape.ReplaceAllString(v, `$1$1`)
50+ return `"` + v + `"`
51+}
52+
53+// dockerCmd is a docker command: the line the runner echoes and the arguments
54+// that are run.
55+type dockerCmd struct {
56+ line string
57+ argv []string
58+}
59+
60+// newDockerCmd builds "docker <command> <options>" the way the runner does
61+// (ExecuteDockerCommandAsync: command and options joined by a space and
62+// trimmed) and keeps the argv apart, so that nothing is re-split.
63+func (j *job) newDockerCmd(command string, parts ...dockerPart) dockerCmd {
64+ lines := []string{}
65+ argv := []string{j.dockerPath(), command}
66+ for _, p := range parts {
67+ lines = append(lines, p.line)
68+ argv = append(argv, p.args...)
69+ }
70+ line := strings.TrimSpace(command + " " + strings.Join(lines, " "))
71+ return dockerCmd{line: j.dockerPath() + " " + line, argv: argv}
72+}
73+
74+// dockerPart is one piece of a docker command: its text in the echoed line and
75+// its arguments.
76+type dockerPart struct {
77+ line string
78+ args []string
79+}
80+
81+func raw(args ...string) dockerPart { return dockerPart{line: strings.Join(args, " "), args: args} }
82+
83+// envOpt is CreateEscapedOption("-e", key): the value is read from the
84+// environment of the docker process.
85+func envOpt(key string) dockerPart {
86+ return dockerPart{line: "-e " + escapeDockerArg(key), args: []string{"-e", key}}
87+}
88+
89+func entrypointOpt(ep string) dockerPart {
90+ return dockerPart{line: `--entrypoint "` + ep + `"`, args: []string{"--entrypoint", ep}}
91+}
92+
93+func volumeOpt(src, dst string) dockerPart {
94+ return dockerPart{
95+ line: `-v "` + strings.ReplaceAll(src, `"`, `\"`) + `":"` + strings.ReplaceAll(dst, `"`, `\"`) + `"`,
96+ args: []string{"-v", src + ":" + dst},
97+ }
98+}
99+
100+// sanitizeName is NameValidation.Sanitize: letters, digits (not first) and "_".
101+func sanitizeName(s string) string {
102+ var b strings.Builder
103+ for _, r := range s {
104+ switch {
105+ case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r == '_':
106+ b.WriteRune(r)
107+ case r >= '0' && r <= '9' && b.Len() > 0:
108+ b.WriteRune(r)
109+ }
110+ }
111+ return b.String()
112+}
113+
114+func newImageTag(label string) string {
115+ return label + ":" + strings.ReplaceAll(uuid.NewString(), "-", "")
116+}
117+
118+// splitArgs splits a command line like .NET does for the arguments of a
119+// process: spaces separate, double quotes group, a backslash before a quote
120+// escapes it (ArgUtil.SplitArguments is not needed: ProcessInvoker passes the
121+// string to the platform, which splits it with these rules).
122+func splitArgs(s string) []string {
123+ var out []string
124+ var cur strings.Builder
125+ in, started := false, false
126+ for i := 0; i < len(s); i++ {
127+ c := s[i]
128+ switch {
129+ case c == '\\':
130+ n := 0
131+ for i < len(s) && s[i] == '\\' {
132+ n++
133+ i++
134+ }
135+ if i < len(s) && s[i] == '"' {
136+ cur.WriteString(strings.Repeat(`\`, n/2))
137+ if n%2 == 1 {
138+ cur.WriteByte('"')
139+ } else {
140+ in = !in
141+ }
142+ } else {
143+ cur.WriteString(strings.Repeat(`\`, n))
144+ i--
145+ }
146+ started = true
147+ case c == '"':
148+ in = !in
149+ started = true
150+ case (c == ' ' || c == '\t') && !in:
151+ if started {
152+ out = append(out, cur.String())
153+ cur.Reset()
154+ started = false
155+ }
156+ default:
157+ cur.WriteByte(c)
158+ started = true
159+ }
160+ }
161+ if started {
162+ out = append(out, cur.String())
163+ }
164+ return out
165+}
166+
167+// runDockerCmd echoes the command (##[command], ExecutionContext.Command) and
168+// runs it, sending every line of its output to the log as is: the output of
169+// docker is not interpreted as workflow commands (ExecuteDockerCommandAsync
170+// writes it with context.Output). It returns the exit code.
171+func (j *job) runDockerCmd(s *stepRun, c dockerCmd, dir string, env []string, cancel <-chan struct{}) (int, error) {
172+ j.out(s.num, "##[command]"+c.line)
173+ if env == nil {
174+ env = j.processEnv(newOrdered())
175+ }
176+ res, err := runProcess(procSpec{
177+ Argv: c.argv, Dir: dir, Env: env, Credential: j.cfg.Credential,
178+ OnLine: func(line string) { j.write(s.num, line) },
179+ Cancel: cancel, TermAfter: j.cfg.TermAfter, KillAfter: j.cfg.KillAfter, WaitDelay: j.cfg.WaitDelay, Signal: j.cfg.Signal,
180+ })
181+ if err != nil {
182+ return 0, err
183+ }
184+ if res.Cancelled {
185+ return res.ExitCode, errCancelled
186+ }
187+ return res.ExitCode, nil
188+}
189+
190+// errCancelled is a docker command stopped by the cancellation of the step.
191+var errCancelled = fmt.Errorf("The operation was canceled.")
192+
193+func dirOrCwd(d string) string {
194+ if st, err := os.Stat(d); err == nil && st.IsDir() {
195+ return d
196+ }
197+ return ""
198+}
A internal/executor/actions_download.go +211 -0
@@ -0,0 +1,211 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "context"
7+ "encoding/base64"
8+ "errors"
9+ "fmt"
10+ "io"
11+ "math/rand/v2"
12+ "net/http"
13+ "os"
14+ "path/filepath"
15+ "time"
16+
17+ "github.com/google/uuid"
18+)
19+
20+// Constants of ActionManager.DownloadRepositoryArchive (ActionManager.cs:1600-1760).
21+const (
22+ downloadAttempts = 3
23+ downloadTimeout = 20 * time.Minute
24+ // maxDownloadBytes bounds an archive (the runner has no bound).
25+ maxDownloadBytes = 1 << 30
26+)
27+
28+// noBackoffEnv is the runner's switch for tests (ActionManager.cs:1739).
29+const noBackoffEnv = "_GITHUB_ACTION_DOWNLOAD_NO_BACKOFF"
30+
31+// errActionNotFound is ActionNotFoundException (ActionNotFoundException.cs:28).
32+type errActionNotFound struct{ url string }
33+
34+func (e errActionNotFound) Error() string {
35+ return fmt.Sprintf("An action could not be found at the URI '%s'", e.url)
36+}
37+
38+// errAccessDenied is AccessDeniedException; the request id is empty (ermite
39+// has none) and the runner still prints its parentheses.
40+type errAccessDenied struct{ url string }
41+
42+func (e errAccessDenied) Error() string { return fmt.Sprintf("Access denied to '%s' ()", e.url) }
43+
44+func (j *job) httpClient() *http.Client {
45+ if c := j.cfg.Actions.HTTPClient; c != nil {
46+ return c
47+ }
48+ return &http.Client{}
49+}
50+
51+// sleepBackoff waits a random 10 to 30 seconds (BackoffTimerHelper.
52+// GetRandomBackoff), or what the test hook says.
53+func (j *job) sleepBackoff(ctx context.Context) time.Duration {
54+ if d := j.cfg.Actions.Backoff; d != nil {
55+ return d()
56+ }
57+ return time.Duration(10+rand.Int64N(21)) * time.Second
58+}
59+
60+// downloadArchive saves the archive of an action in file, retrying like the
61+// runner: three attempts, a warning after each failure, a 404 or a 403 at once.
62+// The final error is the one of the last attempt, which is what the runner
63+// shows (PrepareActionsAsync prints the inner exception of
64+// FailedToDownloadActionException).
65+func (j *job) downloadArchive(ctx context.Context, num int, url, token, file string) error {
66+ var last error
67+ for attempt := 1; attempt <= downloadAttempts; attempt++ {
68+ err := j.fetch(ctx, url, token, file)
69+ if err == nil {
70+ return nil
71+ }
72+ last = err
73+ var nf errActionNotFound
74+ var ad errAccessDenied
75+ if ctx.Err() != nil || errors.As(err, &ad) {
76+ return err
77+ }
78+ if errors.As(err, &nf) {
79+ return err
80+ }
81+ if attempt == downloadAttempts {
82+ break
83+ }
84+ j.tagged(num, "##[warning]", fmt.Sprintf("Failed to download action '%s'. Error: %s ", url, err.Error()))
85+ if os.Getenv(noBackoffEnv) == "" {
86+ d := j.sleepBackoff(ctx)
87+ j.tagged(num, "##[warning]", fmt.Sprintf("Back off %v seconds before retry.", d.Seconds()))
88+ select {
89+ case <-time.After(d):
90+ case <-ctx.Done():
91+ return ctx.Err()
92+ }
93+ }
94+ }
95+ return last
96+}
97+
98+func (j *job) fetch(ctx context.Context, url, token, file string) error {
99+ ctx, cancel := context.WithTimeout(ctx, downloadTimeout)
100+ defer cancel()
101+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
102+ if err != nil {
103+ return err
104+ }
105+ if token != "" {
106+ req.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte("x-access-token:"+token)))
107+ }
108+ req.Header.Set("User-Agent", "ermite-runner")
109+ resp, err := j.httpClient().Do(req)
110+ if err != nil {
111+ return err
112+ }
113+ defer func() { _ = resp.Body.Close() }()
114+ switch {
115+ case resp.StatusCode == http.StatusNotFound:
116+ return errActionNotFound{url}
117+ case resp.StatusCode == http.StatusForbidden:
118+ return errAccessDenied{url}
119+ case resp.StatusCode < 200 || resp.StatusCode > 299:
120+ // HttpResponseMessage.EnsureSuccessStatusCode.
121+ return fmt.Errorf("Response status code does not indicate success: %d (%s).", resp.StatusCode, http.StatusText(resp.StatusCode))
122+ }
123+ f, err := os.OpenFile(file, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
124+ if err != nil {
125+ return err
126+ }
127+ n, err := io.Copy(f, io.LimitReader(resp.Body, maxDownloadBytes+1))
128+ if cerr := f.Close(); err == nil {
129+ err = cerr
130+ }
131+ if err == nil && n > maxDownloadBytes {
132+ err = errors.New("the archive is larger than 1 GiB")
133+ }
134+ return err
135+}
136+
137+// watermark is the file that says an action was downloaded
138+// (ActionManager.GetWatermarkFilePath).
139+func watermark(dir string) string { return dir + ".completed" }
140+
141+// downloadAction downloads and unpacks one action into dir
142+// (ActionManager.DownloadRepositoryActionAsync): in a temporary directory, the
143+// archive's single top directory stripped, renamed to dir on success, and
144+// marked with the watermark.
145+func (j *job) downloadAction(ctx context.Context, num int, ra ResolvedAction, name, ref, dir string) error {
146+ if _, err := os.Stat(watermark(dir)); err == nil {
147+ j.debug(num, fmt.Sprintf("Action '%s@%s' already downloaded at '%s'.", name, ref, dir))
148+ return nil
149+ }
150+ j.out(num, fmt.Sprintf("Download action repository '%s@%s' (SHA:%s)", name, ref, ra.ResolvedSHA))
151+
152+ actions := j.state.Layout().Actions
153+ tmp := filepath.Join(actions, "_temp_"+uuid.NewString())
154+ if err := os.MkdirAll(tmp, 0o755); err != nil {
155+ return err
156+ }
157+ defer func() { _ = os.RemoveAll(tmp) }()
158+ archive := filepath.Join(tmp, uuid.NewString()+".tar.gz")
159+ if err := j.downloadArchive(ctx, num, ra.TarballURL, ra.Token, archive); err != nil {
160+ return err
161+ }
162+ staging := filepath.Join(tmp, "_staging")
163+ if err := os.Mkdir(staging, 0o755); err != nil {
164+ return err
165+ }
166+ af, err := os.Open(archive)
167+ if err != nil {
168+ return err
169+ }
170+ var owner *[2]int
171+ if c := j.cfg.Credential; c != nil {
172+ owner = &[2]int{int(c.Uid), int(c.Gid)}
173+ }
174+ err = extractTarball(af, staging, owner)
175+ _ = af.Close()
176+ if err != nil {
177+ return fmt.Errorf("Can't extract the archive of action %s@%s: %w", name, ref, err)
178+ }
179+ ents, err := os.ReadDir(staging)
180+ if err != nil {
181+ return err
182+ }
183+ var dirs []os.DirEntry
184+ for _, e := range ents {
185+ if e.IsDir() {
186+ dirs = append(dirs, e)
187+ }
188+ }
189+ if len(dirs) != 1 || len(ents) != 1 {
190+ return fmt.Errorf("'%s' contains '%d' directories", archive, len(dirs))
191+ }
192+ _ = os.RemoveAll(dir)
193+ if err := os.MkdirAll(filepath.Dir(dir), 0o755); err != nil {
194+ return err
195+ }
196+ if err := os.Rename(filepath.Join(staging, dirs[0].Name()), dir); err != nil {
197+ return err
198+ }
199+ j.debug(num, fmt.Sprintf("Unwrap '%s' to '%s'", dirs[0].Name(), dir))
200+ if err := os.WriteFile(watermark(dir), []byte(j.now().UTC().Format(time.DateTime)), 0o644); err != nil {
201+ return err
202+ }
203+ j.own(dir)
204+ _ = filepath.WalkDir(dir, func(p string, _ os.DirEntry, err error) error {
205+ if err == nil {
206+ j.own(p)
207+ }
208+ return nil
209+ })
210+ return nil
211+}
@@ -0,0 +1,211 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "context"
7+ "encoding/base64"
8+ "errors"
9+ "fmt"
10+ "io"
11+ "math/rand/v2"
12+ "net/http"
13+ "os"
14+ "path/filepath"
15+ "time"
16+
17+ "github.com/google/uuid"
18+)
19+
20+// Constants of ActionManager.DownloadRepositoryArchive (ActionManager.cs:1600-1760).
21+const (
22+ downloadAttempts = 3
23+ downloadTimeout = 20 * time.Minute
24+ // maxDownloadBytes bounds an archive (the runner has no bound).
25+ maxDownloadBytes = 1 << 30
26+)
27+
28+// noBackoffEnv is the runner's switch for tests (ActionManager.cs:1739).
29+const noBackoffEnv = "_GITHUB_ACTION_DOWNLOAD_NO_BACKOFF"
30+
31+// errActionNotFound is ActionNotFoundException (ActionNotFoundException.cs:28).
32+type errActionNotFound struct{ url string }
33+
34+func (e errActionNotFound) Error() string {
35+ return fmt.Sprintf("An action could not be found at the URI '%s'", e.url)
36+}
37+
38+// errAccessDenied is AccessDeniedException; the request id is empty (ermite
39+// has none) and the runner still prints its parentheses.
40+type errAccessDenied struct{ url string }
41+
42+func (e errAccessDenied) Error() string { return fmt.Sprintf("Access denied to '%s' ()", e.url) }
43+
44+func (j *job) httpClient() *http.Client {
45+ if c := j.cfg.Actions.HTTPClient; c != nil {
46+ return c
47+ }
48+ return &http.Client{}
49+}
50+
51+// sleepBackoff waits a random 10 to 30 seconds (BackoffTimerHelper.
52+// GetRandomBackoff), or what the test hook says.
53+func (j *job) sleepBackoff(ctx context.Context) time.Duration {
54+ if d := j.cfg.Actions.Backoff; d != nil {
55+ return d()
56+ }
57+ return time.Duration(10+rand.Int64N(21)) * time.Second
58+}
59+
60+// downloadArchive saves the archive of an action in file, retrying like the
61+// runner: three attempts, a warning after each failure, a 404 or a 403 at once.
62+// The final error is the one of the last attempt, which is what the runner
63+// shows (PrepareActionsAsync prints the inner exception of
64+// FailedToDownloadActionException).
65+func (j *job) downloadArchive(ctx context.Context, num int, url, token, file string) error {
66+ var last error
67+ for attempt := 1; attempt <= downloadAttempts; attempt++ {
68+ err := j.fetch(ctx, url, token, file)
69+ if err == nil {
70+ return nil
71+ }
72+ last = err
73+ var nf errActionNotFound
74+ var ad errAccessDenied
75+ if ctx.Err() != nil || errors.As(err, &ad) {
76+ return err
77+ }
78+ if errors.As(err, &nf) {
79+ return err
80+ }
81+ if attempt == downloadAttempts {
82+ break
83+ }
84+ j.tagged(num, "##[warning]", fmt.Sprintf("Failed to download action '%s'. Error: %s ", url, err.Error()))
85+ if os.Getenv(noBackoffEnv) == "" {
86+ d := j.sleepBackoff(ctx)
87+ j.tagged(num, "##[warning]", fmt.Sprintf("Back off %v seconds before retry.", d.Seconds()))
88+ select {
89+ case <-time.After(d):
90+ case <-ctx.Done():
91+ return ctx.Err()
92+ }
93+ }
94+ }
95+ return last
96+}
97+
98+func (j *job) fetch(ctx context.Context, url, token, file string) error {
99+ ctx, cancel := context.WithTimeout(ctx, downloadTimeout)
100+ defer cancel()
101+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
102+ if err != nil {
103+ return err
104+ }
105+ if token != "" {
106+ req.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte("x-access-token:"+token)))
107+ }
108+ req.Header.Set("User-Agent", "ermite-runner")
109+ resp, err := j.httpClient().Do(req)
110+ if err != nil {
111+ return err
112+ }
113+ defer func() { _ = resp.Body.Close() }()
114+ switch {
115+ case resp.StatusCode == http.StatusNotFound:
116+ return errActionNotFound{url}
117+ case resp.StatusCode == http.StatusForbidden:
118+ return errAccessDenied{url}
119+ case resp.StatusCode < 200 || resp.StatusCode > 299:
120+ // HttpResponseMessage.EnsureSuccessStatusCode.
121+ return fmt.Errorf("Response status code does not indicate success: %d (%s).", resp.StatusCode, http.StatusText(resp.StatusCode))
122+ }
123+ f, err := os.OpenFile(file, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
124+ if err != nil {
125+ return err
126+ }
127+ n, err := io.Copy(f, io.LimitReader(resp.Body, maxDownloadBytes+1))
128+ if cerr := f.Close(); err == nil {
129+ err = cerr
130+ }
131+ if err == nil && n > maxDownloadBytes {
132+ err = errors.New("the archive is larger than 1 GiB")
133+ }
134+ return err
135+}
136+
137+// watermark is the file that says an action was downloaded
138+// (ActionManager.GetWatermarkFilePath).
139+func watermark(dir string) string { return dir + ".completed" }
140+
141+// downloadAction downloads and unpacks one action into dir
142+// (ActionManager.DownloadRepositoryActionAsync): in a temporary directory, the
143+// archive's single top directory stripped, renamed to dir on success, and
144+// marked with the watermark.
145+func (j *job) downloadAction(ctx context.Context, num int, ra ResolvedAction, name, ref, dir string) error {
146+ if _, err := os.Stat(watermark(dir)); err == nil {
147+ j.debug(num, fmt.Sprintf("Action '%s@%s' already downloaded at '%s'.", name, ref, dir))
148+ return nil
149+ }
150+ j.out(num, fmt.Sprintf("Download action repository '%s@%s' (SHA:%s)", name, ref, ra.ResolvedSHA))
151+
152+ actions := j.state.Layout().Actions
153+ tmp := filepath.Join(actions, "_temp_"+uuid.NewString())
154+ if err := os.MkdirAll(tmp, 0o755); err != nil {
155+ return err
156+ }
157+ defer func() { _ = os.RemoveAll(tmp) }()
158+ archive := filepath.Join(tmp, uuid.NewString()+".tar.gz")
159+ if err := j.downloadArchive(ctx, num, ra.TarballURL, ra.Token, archive); err != nil {
160+ return err
161+ }
162+ staging := filepath.Join(tmp, "_staging")
163+ if err := os.Mkdir(staging, 0o755); err != nil {
164+ return err
165+ }
166+ af, err := os.Open(archive)
167+ if err != nil {
168+ return err
169+ }
170+ var owner *[2]int
171+ if c := j.cfg.Credential; c != nil {
172+ owner = &[2]int{int(c.Uid), int(c.Gid)}
173+ }
174+ err = extractTarball(af, staging, owner)
175+ _ = af.Close()
176+ if err != nil {
177+ return fmt.Errorf("Can't extract the archive of action %s@%s: %w", name, ref, err)
178+ }
179+ ents, err := os.ReadDir(staging)
180+ if err != nil {
181+ return err
182+ }
183+ var dirs []os.DirEntry
184+ for _, e := range ents {
185+ if e.IsDir() {
186+ dirs = append(dirs, e)
187+ }
188+ }
189+ if len(dirs) != 1 || len(ents) != 1 {
190+ return fmt.Errorf("'%s' contains '%d' directories", archive, len(dirs))
191+ }
192+ _ = os.RemoveAll(dir)
193+ if err := os.MkdirAll(filepath.Dir(dir), 0o755); err != nil {
194+ return err
195+ }
196+ if err := os.Rename(filepath.Join(staging, dirs[0].Name()), dir); err != nil {
197+ return err
198+ }
199+ j.debug(num, fmt.Sprintf("Unwrap '%s' to '%s'", dirs[0].Name(), dir))
200+ if err := os.WriteFile(watermark(dir), []byte(j.now().UTC().Format(time.DateTime)), 0o644); err != nil {
201+ return err
202+ }
203+ j.own(dir)
204+ _ = filepath.WalkDir(dir, func(p string, _ os.DirEntry, err error) error {
205+ if err == nil {
206+ j.own(p)
207+ }
208+ return nil
209+ })
210+ return nil
211+}
A internal/executor/actions_inputs.go +139 -0
@@ -0,0 +1,139 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "context"
7+ "fmt"
8+ "strings"
9+
10+ "bnema.dev/ermite-actions/action"
11+ "bnema.dev/ermite-actions/expr"
12+ "bnema.dev/ermite-actions/template"
13+ "bnema.dev/ermite-actions/workflow"
14+)
15+
16+// inputSet is the inputs of a step: a dictionary with OrdinalIgnoreCase keys
17+// that keeps insertion order (Dictionary<string,string>, which only keeps it
18+// while nothing is removed).
19+type inputSet struct {
20+ keys []string
21+ vals map[string]string
22+}
23+
24+func newInputSet() *inputSet { return &inputSet{vals: map[string]string{}} }
25+
26+func (in *inputSet) lower(k string) string { return strings.ToLower(k) }
27+
28+func (in *inputSet) Set(k, v string) {
29+ l := in.lower(k)
30+ if _, ok := in.vals[l]; !ok {
31+ in.keys = append(in.keys, k)
32+ }
33+ in.vals[l] = v
34+}
35+
36+func (in *inputSet) Get(k string) (string, bool) { v, ok := in.vals[in.lower(k)]; return v, ok }
37+func (in *inputSet) Has(k string) bool { _, ok := in.vals[in.lower(k)]; return ok }
38+func (in *inputSet) Len() int { return len(in.keys) }
39+
40+// evalInputs is EvaluateStepInputs: the "with" of the step (a mapping, or the
41+// expression of a composite action's step that stands for one).
42+func (j *job) evalInputs(st *workflow.Step, env *ordered) (*inputSet, error) {
43+ in := newInputSet()
44+ var tok template.Token
45+ switch {
46+ case st.With != nil:
47+ tok = st.With
48+ case st.WithExpression != nil:
49+ tok = st.WithExpression
50+ default:
51+ return in, nil
52+ }
53+ v, err := template.Evaluate(context.Background(), tok, j.evalEnv(env))
54+ if err != nil {
55+ return nil, err
56+ }
57+ d, ok := v.(*expr.Dictionary)
58+ if !ok {
59+ return in, nil
60+ }
61+ for _, p := range d.Pairs() {
62+ if k := p.Value.Kind(); k == expr.KindArray || k == expr.KindDictionary {
63+ return nil, ghErrorf("Unexpected type '%s' encountered while reading 'inputs value'. The type 'StringToken' was expected.", k)
64+ }
65+ in.Set(p.Key, expr.CoerceString(p.Value))
66+ }
67+ return in, nil
68+}
69+
70+// mergeInputs is the second half of ActionRunner.RunAsync's input handling
71+// (ActionRunner.cs:121-186): deprecation warnings for the inputs the step
72+// gives, the defaults of the others (evaluated in the context of the step), and
73+// the warning about inputs the action does not declare. The missing required
74+// inputs are not checked: the runner has no such check.
75+func (j *job) mergeInputs(s *stepRun, def *actionDef, in *inputSet) error {
76+ var act *action.Action
77+ if def != nil {
78+ act = def.act
79+ }
80+ user := append([]string(nil), in.keys...)
81+ if act != nil {
82+ for _, k := range user {
83+ for i := range act.Inputs {
84+ if act.Inputs[i].Name == k && act.Inputs[i].Deprecated() {
85+ j.tagged(s.num, "##[warning]", fmt.Sprintf("Input '%s' has been deprecated with message: %s", k, act.Inputs[i].DeprecationMessage))
86+ }
87+ }
88+ }
89+ }
90+ valid := newInputSet()
91+ if def != nil && def.kind == kindDocker {
92+ // A container action always accepts 'entryPoint' and 'args'.
93+ valid.Set("entryPoint", "")
94+ valid.Set("args", "")
95+ }
96+ if act != nil {
97+ for i := range act.Inputs {
98+ name := act.Inputs[i].Name
99+ valid.Set(name, "")
100+ if in.Has(name) {
101+ continue
102+ }
103+ val, err := j.evalDefault(act.Inputs[i].Default, s.env)
104+ if err != nil {
105+ return err
106+ }
107+ in.Set(name, val)
108+ }
109+ }
110+ // Only for actions with a manifest or a repository reference (not docker://).
111+ if s.n.uses.Kind == action.UsesDocker {
112+ return nil
113+ }
114+ var unexpected []string
115+ for _, k := range user {
116+ if !valid.Has(k) {
117+ unexpected = append(unexpected, k)
118+ }
119+ }
120+ if len(unexpected) > 0 {
121+ j.tagged(s.num, "##[warning]", fmt.Sprintf("Unexpected input(s) '%s', valid inputs are ['%s']",
122+ strings.Join(unexpected, "', '"), strings.Join(valid.keys, "', '")))
123+ }
124+ return nil
125+}
126+
127+// evalDefault is EvaluateDefaultInput: the default is a string or an expression
128+// over github, strategy, matrix, job, runner and hashFiles.
129+func (j *job) evalDefault(tok template.Token, env *ordered) (string, error) {
130+ if tok == nil {
131+ return "", nil
132+ }
133+ return j.evalString(tok, env)
134+}
135+
136+// inputEnvName is the variable of an input (Handler.AddInputsToEnvironment).
137+func inputEnvName(key string) string {
138+ return "INPUT_" + strings.ToUpper(strings.ReplaceAll(key, " ", "_"))
139+}
@@ -0,0 +1,139 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "context"
7+ "fmt"
8+ "strings"
9+
10+ "bnema.dev/ermite-actions/action"
11+ "bnema.dev/ermite-actions/expr"
12+ "bnema.dev/ermite-actions/template"
13+ "bnema.dev/ermite-actions/workflow"
14+)
15+
16+// inputSet is the inputs of a step: a dictionary with OrdinalIgnoreCase keys
17+// that keeps insertion order (Dictionary<string,string>, which only keeps it
18+// while nothing is removed).
19+type inputSet struct {
20+ keys []string
21+ vals map[string]string
22+}
23+
24+func newInputSet() *inputSet { return &inputSet{vals: map[string]string{}} }
25+
26+func (in *inputSet) lower(k string) string { return strings.ToLower(k) }
27+
28+func (in *inputSet) Set(k, v string) {
29+ l := in.lower(k)
30+ if _, ok := in.vals[l]; !ok {
31+ in.keys = append(in.keys, k)
32+ }
33+ in.vals[l] = v
34+}
35+
36+func (in *inputSet) Get(k string) (string, bool) { v, ok := in.vals[in.lower(k)]; return v, ok }
37+func (in *inputSet) Has(k string) bool { _, ok := in.vals[in.lower(k)]; return ok }
38+func (in *inputSet) Len() int { return len(in.keys) }
39+
40+// evalInputs is EvaluateStepInputs: the "with" of the step (a mapping, or the
41+// expression of a composite action's step that stands for one).
42+func (j *job) evalInputs(st *workflow.Step, env *ordered) (*inputSet, error) {
43+ in := newInputSet()
44+ var tok template.Token
45+ switch {
46+ case st.With != nil:
47+ tok = st.With
48+ case st.WithExpression != nil:
49+ tok = st.WithExpression
50+ default:
51+ return in, nil
52+ }
53+ v, err := template.Evaluate(context.Background(), tok, j.evalEnv(env))
54+ if err != nil {
55+ return nil, err
56+ }
57+ d, ok := v.(*expr.Dictionary)
58+ if !ok {
59+ return in, nil
60+ }
61+ for _, p := range d.Pairs() {
62+ if k := p.Value.Kind(); k == expr.KindArray || k == expr.KindDictionary {
63+ return nil, ghErrorf("Unexpected type '%s' encountered while reading 'inputs value'. The type 'StringToken' was expected.", k)
64+ }
65+ in.Set(p.Key, expr.CoerceString(p.Value))
66+ }
67+ return in, nil
68+}
69+
70+// mergeInputs is the second half of ActionRunner.RunAsync's input handling
71+// (ActionRunner.cs:121-186): deprecation warnings for the inputs the step
72+// gives, the defaults of the others (evaluated in the context of the step), and
73+// the warning about inputs the action does not declare. The missing required
74+// inputs are not checked: the runner has no such check.
75+func (j *job) mergeInputs(s *stepRun, def *actionDef, in *inputSet) error {
76+ var act *action.Action
77+ if def != nil {
78+ act = def.act
79+ }
80+ user := append([]string(nil), in.keys...)
81+ if act != nil {
82+ for _, k := range user {
83+ for i := range act.Inputs {
84+ if act.Inputs[i].Name == k && act.Inputs[i].Deprecated() {
85+ j.tagged(s.num, "##[warning]", fmt.Sprintf("Input '%s' has been deprecated with message: %s", k, act.Inputs[i].DeprecationMessage))
86+ }
87+ }
88+ }
89+ }
90+ valid := newInputSet()
91+ if def != nil && def.kind == kindDocker {
92+ // A container action always accepts 'entryPoint' and 'args'.
93+ valid.Set("entryPoint", "")
94+ valid.Set("args", "")
95+ }
96+ if act != nil {
97+ for i := range act.Inputs {
98+ name := act.Inputs[i].Name
99+ valid.Set(name, "")
100+ if in.Has(name) {
101+ continue
102+ }
103+ val, err := j.evalDefault(act.Inputs[i].Default, s.env)
104+ if err != nil {
105+ return err
106+ }
107+ in.Set(name, val)
108+ }
109+ }
110+ // Only for actions with a manifest or a repository reference (not docker://).
111+ if s.n.uses.Kind == action.UsesDocker {
112+ return nil
113+ }
114+ var unexpected []string
115+ for _, k := range user {
116+ if !valid.Has(k) {
117+ unexpected = append(unexpected, k)
118+ }
119+ }
120+ if len(unexpected) > 0 {
121+ j.tagged(s.num, "##[warning]", fmt.Sprintf("Unexpected input(s) '%s', valid inputs are ['%s']",
122+ strings.Join(unexpected, "', '"), strings.Join(valid.keys, "', '")))
123+ }
124+ return nil
125+}
126+
127+// evalDefault is EvaluateDefaultInput: the default is a string or an expression
128+// over github, strategy, matrix, job, runner and hashFiles.
129+func (j *job) evalDefault(tok template.Token, env *ordered) (string, error) {
130+ if tok == nil {
131+ return "", nil
132+ }
133+ return j.evalString(tok, env)
134+}
135+
136+// inputEnvName is the variable of an input (Handler.AddInputsToEnvironment).
137+func inputEnvName(key string) string {
138+ return "INPUT_" + strings.ToUpper(strings.ReplaceAll(key, " ", "_"))
139+}
A internal/executor/actions_job.go +46 -0
@@ -0,0 +1,46 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import "os"
6+
7+// prepareJob is the actions part of "Set up job" (ActionManager.PrepareActionsAsync
8+// and JobExtension.cs:299-362): resolve and download the actions of the job,
9+// and build the queue of the steps: the steps that pull and build images, the
10+// pre stages in the order of the steps, then the steps themselves.
11+func (j *job) prepareJob() ([]qstep, error) {
12+ j.actions = newActionState()
13+ // The actions of a previous job are not reused (ActionManager.cs:97-100).
14+ _ = os.RemoveAll(j.state.Layout().Actions)
15+ if err := os.MkdirAll(j.state.Layout().Actions, 0o755); err != nil {
16+ return nil, err
17+ }
18+ j.own(j.state.Layout().Actions)
19+ nodes, err := newNodes(j.jb.Steps, nil, "")
20+ if err != nil {
21+ return nil, err
22+ }
23+ j.actions.nodes = nodes
24+ if err := j.prepare(j.ctx(), 1, nodes, 0, nil); err != nil {
25+ return nil, j.setupError(err)
26+ }
27+ queue := j.setupSteps()
28+ for _, n := range nodes {
29+ if j.actions.pre[n] {
30+ queue = append(queue, qstep{n: n, stage: stagePre, cond: n.def.preIf()})
31+ }
32+ }
33+ // The pre steps come before every main step: they were collected while
34+ // walking the steps, the mains follow.
35+ for _, n := range nodes {
36+ queue = append(queue, qstep{n: n, stage: stageMain})
37+ }
38+ return queue, nil
39+}
40+
41+// setupError words an error of the preparation like the runner reports it:
42+// errors of the manifests were logged already; "Failed to resolve action
43+// download info." and "Failed to download archive ..." carry the inner message.
44+func (j *job) setupError(err error) error {
45+ return err
46+}
@@ -0,0 +1,46 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import "os"
6+
7+// prepareJob is the actions part of "Set up job" (ActionManager.PrepareActionsAsync
8+// and JobExtension.cs:299-362): resolve and download the actions of the job,
9+// and build the queue of the steps: the steps that pull and build images, the
10+// pre stages in the order of the steps, then the steps themselves.
11+func (j *job) prepareJob() ([]qstep, error) {
12+ j.actions = newActionState()
13+ // The actions of a previous job are not reused (ActionManager.cs:97-100).
14+ _ = os.RemoveAll(j.state.Layout().Actions)
15+ if err := os.MkdirAll(j.state.Layout().Actions, 0o755); err != nil {
16+ return nil, err
17+ }
18+ j.own(j.state.Layout().Actions)
19+ nodes, err := newNodes(j.jb.Steps, nil, "")
20+ if err != nil {
21+ return nil, err
22+ }
23+ j.actions.nodes = nodes
24+ if err := j.prepare(j.ctx(), 1, nodes, 0, nil); err != nil {
25+ return nil, j.setupError(err)
26+ }
27+ queue := j.setupSteps()
28+ for _, n := range nodes {
29+ if j.actions.pre[n] {
30+ queue = append(queue, qstep{n: n, stage: stagePre, cond: n.def.preIf()})
31+ }
32+ }
33+ // The pre steps come before every main step: they were collected while
34+ // walking the steps, the mains follow.
35+ for _, n := range nodes {
36+ queue = append(queue, qstep{n: n, stage: stageMain})
37+ }
38+ return queue, nil
39+}
40+
41+// setupError words an error of the preparation like the runner reports it:
42+// errors of the manifests were logged already; "Failed to resolve action
43+// download info." and "Failed to download archive ..." carry the inner message.
44+func (j *job) setupError(err error) error {
45+ return err
46+}
A internal/executor/actions_load.go +123 -0
@@ -0,0 +1,123 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "fmt"
7+ "os"
8+ "path/filepath"
9+ "strings"
10+
11+ "bnema.dev/ermite-actions/action"
12+)
13+
14+// loadManifest reads action.yml or action.yaml (the first is preferred) from
15+// dir, like ActionManager.LoadAction (ActionManager.cs:722-731) and
16+// ActionManifestManagerLegacy.Load (:53-160): every error of the manifest is
17+// logged as an error of the step, then the load fails with "Failed to load
18+// <path>". shown is the path the messages use: relative to the actions
19+// directory for downloaded actions, the whole path for local ones
20+// (ActionManifestManagerLegacy.cs:61-66). It returns (nil, "", nil) without a
21+// manifest.
22+func (j *job) loadManifest(num int, dir string) (*action.Action, string, error) {
23+ var file string
24+ for _, name := range []string{"action.yml", "action.yaml"} {
25+ if _, err := os.Stat(filepath.Join(dir, name)); err == nil {
26+ file = filepath.Join(dir, name)
27+ break
28+ }
29+ }
30+ if file == "" {
31+ return nil, "", nil
32+ }
33+ shown := file
34+ if base := j.state.Layout().Actions; strings.HasPrefix(file, base+string(filepath.Separator)) {
35+ shown = file[len(base)+1:]
36+ }
37+ data, err := os.ReadFile(file)
38+ if err != nil {
39+ return nil, "", err
40+ }
41+ act, errs := action.Parse(shown, data)
42+ if len(errs) > 0 {
43+ for _, e := range errs {
44+ j.errorf(num, "%s", e.Error())
45+ }
46+ return nil, "", ghErrorf("%s", action.LoadFailed(shown))
47+ }
48+ return act, shown, nil
49+}
50+
51+func kindOfRuns(r *action.Runs) kind {
52+ switch r.Kind {
53+ case action.RunsNode:
54+ return kindNode
55+ case action.RunsDocker:
56+ return kindDocker
57+ case action.RunsComposite:
58+ return kindComposite
59+ }
60+ return kindScript
61+}
62+
63+// loadDef loads the action in dir (ActionManager.LoadAction): its manifest, or
64+// a Dockerfile when there is none (Dockerfile before dockerfile). what names
65+// the action in the error for a directory without any.
66+func (j *job) loadDef(num int, dir string) (*actionDef, error) {
67+ act, _, err := j.loadManifest(num, dir)
68+ if err != nil {
69+ return nil, err
70+ }
71+ if act != nil {
72+ d := &actionDef{kind: kindOfRuns(act.Runs), dir: dir, act: act}
73+ switch act.Runs.Kind {
74+ case action.RunsDocker:
75+ d.image = act.Runs.Image
76+ case action.RunsPlugin:
77+ return nil, fmt.Errorf("Unsupported action type: Plugin")
78+ }
79+ return d, nil
80+ }
81+ for _, name := range []string{"Dockerfile", "dockerfile"} {
82+ if _, err := os.Stat(filepath.Join(dir, name)); err == nil {
83+ return &actionDef{kind: kindDocker, dir: dir, dockerfile: filepath.Join(dir, name)}, nil
84+ }
85+ }
86+ return nil, errNoAction
87+}
88+
89+// errNoAction is the sentinel of a directory without manifest and Dockerfile.
90+var errNoAction = fmt.Errorf("no action")
91+
92+// actionDir is where the action of a node lives: the workspace for a local
93+// action, the download directory for a repository one (ActionManager.cs:665-694).
94+func (j *job) actionDir(n *node) string {
95+ switch n.uses.Kind {
96+ case action.UsesLocal:
97+ return filepath.Join(j.state.GitHub("workspace"), filepath.FromSlash(strings.ReplaceAll(n.uses.Path, `\`, "/")))
98+ case action.UsesRepository:
99+ d := filepath.Join(j.state.Layout().Actions, filepath.FromSlash(n.uses.Name), n.uses.Ref)
100+ if n.uses.Path != "" {
101+ d = filepath.Join(d, filepath.FromSlash(n.uses.Path))
102+ }
103+ return d
104+ }
105+ return ""
106+}
107+
108+// refString is "Name[/Path]@Ref" (ActionRunner.cs:119-121, Handler.cs:130-148).
109+func refString(u action.Uses) string {
110+ switch u.Kind {
111+ case action.UsesRepository:
112+ p := u.Name
113+ if u.Path != "" {
114+ p += "/" + u.Path
115+ }
116+ return p + "@" + u.Ref
117+ case action.UsesLocal:
118+ return u.Path
119+ case action.UsesDocker:
120+ return "docker://" + u.Image
121+ }
122+ return ""
123+}
@@ -0,0 +1,123 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "fmt"
7+ "os"
8+ "path/filepath"
9+ "strings"
10+
11+ "bnema.dev/ermite-actions/action"
12+)
13+
14+// loadManifest reads action.yml or action.yaml (the first is preferred) from
15+// dir, like ActionManager.LoadAction (ActionManager.cs:722-731) and
16+// ActionManifestManagerLegacy.Load (:53-160): every error of the manifest is
17+// logged as an error of the step, then the load fails with "Failed to load
18+// <path>". shown is the path the messages use: relative to the actions
19+// directory for downloaded actions, the whole path for local ones
20+// (ActionManifestManagerLegacy.cs:61-66). It returns (nil, "", nil) without a
21+// manifest.
22+func (j *job) loadManifest(num int, dir string) (*action.Action, string, error) {
23+ var file string
24+ for _, name := range []string{"action.yml", "action.yaml"} {
25+ if _, err := os.Stat(filepath.Join(dir, name)); err == nil {
26+ file = filepath.Join(dir, name)
27+ break
28+ }
29+ }
30+ if file == "" {
31+ return nil, "", nil
32+ }
33+ shown := file
34+ if base := j.state.Layout().Actions; strings.HasPrefix(file, base+string(filepath.Separator)) {
35+ shown = file[len(base)+1:]
36+ }
37+ data, err := os.ReadFile(file)
38+ if err != nil {
39+ return nil, "", err
40+ }
41+ act, errs := action.Parse(shown, data)
42+ if len(errs) > 0 {
43+ for _, e := range errs {
44+ j.errorf(num, "%s", e.Error())
45+ }
46+ return nil, "", ghErrorf("%s", action.LoadFailed(shown))
47+ }
48+ return act, shown, nil
49+}
50+
51+func kindOfRuns(r *action.Runs) kind {
52+ switch r.Kind {
53+ case action.RunsNode:
54+ return kindNode
55+ case action.RunsDocker:
56+ return kindDocker
57+ case action.RunsComposite:
58+ return kindComposite
59+ }
60+ return kindScript
61+}
62+
63+// loadDef loads the action in dir (ActionManager.LoadAction): its manifest, or
64+// a Dockerfile when there is none (Dockerfile before dockerfile). what names
65+// the action in the error for a directory without any.
66+func (j *job) loadDef(num int, dir string) (*actionDef, error) {
67+ act, _, err := j.loadManifest(num, dir)
68+ if err != nil {
69+ return nil, err
70+ }
71+ if act != nil {
72+ d := &actionDef{kind: kindOfRuns(act.Runs), dir: dir, act: act}
73+ switch act.Runs.Kind {
74+ case action.RunsDocker:
75+ d.image = act.Runs.Image
76+ case action.RunsPlugin:
77+ return nil, fmt.Errorf("Unsupported action type: Plugin")
78+ }
79+ return d, nil
80+ }
81+ for _, name := range []string{"Dockerfile", "dockerfile"} {
82+ if _, err := os.Stat(filepath.Join(dir, name)); err == nil {
83+ return &actionDef{kind: kindDocker, dir: dir, dockerfile: filepath.Join(dir, name)}, nil
84+ }
85+ }
86+ return nil, errNoAction
87+}
88+
89+// errNoAction is the sentinel of a directory without manifest and Dockerfile.
90+var errNoAction = fmt.Errorf("no action")
91+
92+// actionDir is where the action of a node lives: the workspace for a local
93+// action, the download directory for a repository one (ActionManager.cs:665-694).
94+func (j *job) actionDir(n *node) string {
95+ switch n.uses.Kind {
96+ case action.UsesLocal:
97+ return filepath.Join(j.state.GitHub("workspace"), filepath.FromSlash(strings.ReplaceAll(n.uses.Path, `\`, "/")))
98+ case action.UsesRepository:
99+ d := filepath.Join(j.state.Layout().Actions, filepath.FromSlash(n.uses.Name), n.uses.Ref)
100+ if n.uses.Path != "" {
101+ d = filepath.Join(d, filepath.FromSlash(n.uses.Path))
102+ }
103+ return d
104+ }
105+ return ""
106+}
107+
108+// refString is "Name[/Path]@Ref" (ActionRunner.cs:119-121, Handler.cs:130-148).
109+func refString(u action.Uses) string {
110+ switch u.Kind {
111+ case action.UsesRepository:
112+ p := u.Name
113+ if u.Path != "" {
114+ p += "/" + u.Path
115+ }
116+ return p + "@" + u.Ref
117+ case action.UsesLocal:
118+ return u.Path
119+ case action.UsesDocker:
120+ return "docker://" + u.Image
121+ }
122+ return ""
123+}
A internal/executor/actions_node.go +179 -0
@@ -0,0 +1,179 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "fmt"
7+ "os"
8+ "path/filepath"
9+ "strings"
10+)
11+
12+const (
13+ node20Name = "node20"
14+ node24Name = "node24"
15+ // Node20DeprecationURL is Constants.Runner.NodeMigration.Node20DeprecationUrl.
16+ node20DeprecationURL = "https://github.blog/changelog/2025-09-19-deprecation-of-node-20-on-github-actions-runners/"
17+ forceNode24Var = "FORCE_JAVASCRIPT_ACTIONS_TO_NODE24"
18+ allowUnsecureVar = "ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION"
19+)
20+
21+// convertToBool is StringUtil.ConvertToBoolean with a false default.
22+func convertToBool(v string) bool {
23+ switch strings.ToLower(v) {
24+ case "1", "true", "$true":
25+ return true
26+ }
27+ return false
28+}
29+
30+// envFlag is NodeUtil.GetEnvironmentVariableDetails: the value comes from the
31+// workflow's environment (the env context of the step) when it is set there,
32+// from the process of the runner otherwise.
33+type envFlag struct{ on, fromWorkflow, fromSystem bool }
34+
35+func (j *job) nodeEnvFlag(name string, wf *ordered) envFlag {
36+ var f envFlag
37+ v, found := wf.Get(name)
38+ if found {
39+ f.fromWorkflow = true
40+ f.on = convertToBool(v)
41+ }
42+ sys := envValue(j.baseEnv, name)
43+ f.fromSystem = sys != ""
44+ if !found {
45+ f.on = convertToBool(sys)
46+ }
47+ return f
48+}
49+
50+// nodeVersionDefault is NodeUtil.DetermineActionsNodeVersion
51+// (NodeUtil.cs:50-110) for an action that says node20: the version the action
52+// runs on and the warning of a conflicting configuration. useNode24ByDefault is
53+// the server's flag, ActionsConfig.Node24ByDefault here.
54+func (j *job) nodeVersionDefault(wf *ordered) (version, warning string) {
55+ force := j.nodeEnvFlag(forceNode24Var, wf)
56+ allow := j.nodeEnvFlag(allowUnsecureVar, wf)
57+ byDefault := j.cfg.Actions.Node24ByDefault
58+ bothWorkflow := force.on && allow.on && force.fromWorkflow && allow.fromWorkflow
59+ bothSystem := force.on && allow.on && force.fromSystem && allow.fromSystem
60+ if bothWorkflow || bothSystem {
61+ source := "system"
62+ if bothWorkflow {
63+ source = "workflow"
64+ }
65+ def := node20Name
66+ if byDefault {
67+ def = node24Name
68+ }
69+ 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.",
70+ forceNode24Var, allowUnsecureVar, source, def)
71+ }
72+ if byDefault {
73+ if allow.on {
74+ return node20Name, ""
75+ }
76+ return node24Name, ""
77+ }
78+ if force.on {
79+ return node24Name, ""
80+ }
81+ return node20Name, ""
82+}
83+
84+// nodeVersionFor is the node part of HandlerFactory.Create
85+// (HandlerFactory.cs:56-170): node12 and node16 run on node20, node20 on
86+// whatever DetermineActionsNodeVersion says, node24 on node24. The result is
87+// kept in the step for the handler.
88+func (j *job) nodeVersionFor(s *stepRun, def *actionDef) error {
89+ r := def.runs()
90+ if r == nil || def.kind != kindNode {
91+ return nil
92+ }
93+ v := r.Using
94+ if v == "node12" || v == "node16" {
95+ v = node20Name
96+ }
97+ if v == node20Name {
98+ final, warn := j.nodeVersionDefault(s.env)
99+ v = final
100+ if warn != "" {
101+ j.tagged(s.num, "##[warning]", warn)
102+ }
103+ if final == node24Name {
104+ j.mu.Lock()
105+ j.upgradedToNode24 = appendUnique(j.upgradedToNode24, actionName(s.n))
106+ j.mu.Unlock()
107+ if j.cfg.Actions.Node24ByDefault {
108+ j.out(s.num, "Node 20 is being deprecated. This workflow is running with Node 24 by default. "+
109+ "If you need to temporarily use Node 20, you can set the ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION=true environment variable. "+
110+ "For more information see: "+node20DeprecationURL)
111+ }
112+ }
113+ }
114+ s.nodeVersion = v
115+ return nil
116+}
117+
118+func appendUnique(l []string, v string) []string {
119+ if v == "" {
120+ return l
121+ }
122+ for _, x := range l {
123+ if x == v {
124+ return l
125+ }
126+ }
127+ return append(l, v)
128+}
129+
130+// actionName is HandlerFactory.GetActionName: "Name[/Path]@Ref".
131+func actionName(n *node) string {
132+ return registeredRef(n.uses)
133+}
134+
135+// externalsDir is where the node versions live.
136+func (j *job) externalsDir() string {
137+ if d := j.cfg.Actions.ExternalsDir; d != "" {
138+ return d
139+ }
140+ return defaultExternalsDir
141+}
142+
143+// runNode is NodeScriptActionHandler.RunAsync (NodeScriptActionHandler.cs:
144+// 27-196) on the host of the steps (a job container is phase H): INPUT_
145+// variables and the PATH of the previous steps in the environment, the
146+// script of the stage, node from the externals, run in the workspace.
147+func (j *job) runNode(s *stepRun, def *actionDef, in *inputSet, env *ordered, cancel <-chan struct{}) stepResult {
148+ num := s.num
149+ for _, k := range in.keys {
150+ v, _ := in.Get(k)
151+ env.Set(inputEnvName(k), v)
152+ }
153+ // The PATH of the previous steps is added by procEnv.
154+ env.Remove("NODE_ICU_DATA") // https://github.com/actions/runner/issues/795
155+ r := def.runs()
156+ var target string
157+ switch s.stage {
158+ case stageMain:
159+ target = r.Main
160+ case stagePre:
161+ target = r.Pre
162+ case stagePost:
163+ target = r.Post
164+ }
165+ fail := func(err error) stepResult { j.errorf(num, "%s", err.Error()); return resFailed }
166+ if target == "" {
167+ return fail(fmt.Errorf("Value cannot be null or empty. (Parameter 'target')"))
168+ }
169+ script := filepath.Join(def.dir, filepath.FromSlash(target))
170+ if st, err := os.Stat(script); err != nil || st.IsDir() {
171+ return fail(fmt.Errorf("File not found: '%s'", script))
172+ }
173+ j.mu.Lock()
174+ wd := j.state.GitHub("workspace")
175+ j.mu.Unlock()
176+ node := filepath.Join(j.externalsDir(), s.nodeVersion, "bin", "node")
177+ s.actEnv = env
178+ return j.runProc(s, node, wd, []string{node, script}, "Node Action run completed with exit code %d", cancel)
179+}
@@ -0,0 +1,179 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "fmt"
7+ "os"
8+ "path/filepath"
9+ "strings"
10+)
11+
12+const (
13+ node20Name = "node20"
14+ node24Name = "node24"
15+ // Node20DeprecationURL is Constants.Runner.NodeMigration.Node20DeprecationUrl.
16+ node20DeprecationURL = "https://github.blog/changelog/2025-09-19-deprecation-of-node-20-on-github-actions-runners/"
17+ forceNode24Var = "FORCE_JAVASCRIPT_ACTIONS_TO_NODE24"
18+ allowUnsecureVar = "ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION"
19+)
20+
21+// convertToBool is StringUtil.ConvertToBoolean with a false default.
22+func convertToBool(v string) bool {
23+ switch strings.ToLower(v) {
24+ case "1", "true", "$true":
25+ return true
26+ }
27+ return false
28+}
29+
30+// envFlag is NodeUtil.GetEnvironmentVariableDetails: the value comes from the
31+// workflow's environment (the env context of the step) when it is set there,
32+// from the process of the runner otherwise.
33+type envFlag struct{ on, fromWorkflow, fromSystem bool }
34+
35+func (j *job) nodeEnvFlag(name string, wf *ordered) envFlag {
36+ var f envFlag
37+ v, found := wf.Get(name)
38+ if found {
39+ f.fromWorkflow = true
40+ f.on = convertToBool(v)
41+ }
42+ sys := envValue(j.baseEnv, name)
43+ f.fromSystem = sys != ""
44+ if !found {
45+ f.on = convertToBool(sys)
46+ }
47+ return f
48+}
49+
50+// nodeVersionDefault is NodeUtil.DetermineActionsNodeVersion
51+// (NodeUtil.cs:50-110) for an action that says node20: the version the action
52+// runs on and the warning of a conflicting configuration. useNode24ByDefault is
53+// the server's flag, ActionsConfig.Node24ByDefault here.
54+func (j *job) nodeVersionDefault(wf *ordered) (version, warning string) {
55+ force := j.nodeEnvFlag(forceNode24Var, wf)
56+ allow := j.nodeEnvFlag(allowUnsecureVar, wf)
57+ byDefault := j.cfg.Actions.Node24ByDefault
58+ bothWorkflow := force.on && allow.on && force.fromWorkflow && allow.fromWorkflow
59+ bothSystem := force.on && allow.on && force.fromSystem && allow.fromSystem
60+ if bothWorkflow || bothSystem {
61+ source := "system"
62+ if bothWorkflow {
63+ source = "workflow"
64+ }
65+ def := node20Name
66+ if byDefault {
67+ def = node24Name
68+ }
69+ 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.",
70+ forceNode24Var, allowUnsecureVar, source, def)
71+ }
72+ if byDefault {
73+ if allow.on {
74+ return node20Name, ""
75+ }
76+ return node24Name, ""
77+ }
78+ if force.on {
79+ return node24Name, ""
80+ }
81+ return node20Name, ""
82+}
83+
84+// nodeVersionFor is the node part of HandlerFactory.Create
85+// (HandlerFactory.cs:56-170): node12 and node16 run on node20, node20 on
86+// whatever DetermineActionsNodeVersion says, node24 on node24. The result is
87+// kept in the step for the handler.
88+func (j *job) nodeVersionFor(s *stepRun, def *actionDef) error {
89+ r := def.runs()
90+ if r == nil || def.kind != kindNode {
91+ return nil
92+ }
93+ v := r.Using
94+ if v == "node12" || v == "node16" {
95+ v = node20Name
96+ }
97+ if v == node20Name {
98+ final, warn := j.nodeVersionDefault(s.env)
99+ v = final
100+ if warn != "" {
101+ j.tagged(s.num, "##[warning]", warn)
102+ }
103+ if final == node24Name {
104+ j.mu.Lock()
105+ j.upgradedToNode24 = appendUnique(j.upgradedToNode24, actionName(s.n))
106+ j.mu.Unlock()
107+ if j.cfg.Actions.Node24ByDefault {
108+ j.out(s.num, "Node 20 is being deprecated. This workflow is running with Node 24 by default. "+
109+ "If you need to temporarily use Node 20, you can set the ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION=true environment variable. "+
110+ "For more information see: "+node20DeprecationURL)
111+ }
112+ }
113+ }
114+ s.nodeVersion = v
115+ return nil
116+}
117+
118+func appendUnique(l []string, v string) []string {
119+ if v == "" {
120+ return l
121+ }
122+ for _, x := range l {
123+ if x == v {
124+ return l
125+ }
126+ }
127+ return append(l, v)
128+}
129+
130+// actionName is HandlerFactory.GetActionName: "Name[/Path]@Ref".
131+func actionName(n *node) string {
132+ return registeredRef(n.uses)
133+}
134+
135+// externalsDir is where the node versions live.
136+func (j *job) externalsDir() string {
137+ if d := j.cfg.Actions.ExternalsDir; d != "" {
138+ return d
139+ }
140+ return defaultExternalsDir
141+}
142+
143+// runNode is NodeScriptActionHandler.RunAsync (NodeScriptActionHandler.cs:
144+// 27-196) on the host of the steps (a job container is phase H): INPUT_
145+// variables and the PATH of the previous steps in the environment, the
146+// script of the stage, node from the externals, run in the workspace.
147+func (j *job) runNode(s *stepRun, def *actionDef, in *inputSet, env *ordered, cancel <-chan struct{}) stepResult {
148+ num := s.num
149+ for _, k := range in.keys {
150+ v, _ := in.Get(k)
151+ env.Set(inputEnvName(k), v)
152+ }
153+ // The PATH of the previous steps is added by procEnv.
154+ env.Remove("NODE_ICU_DATA") // https://github.com/actions/runner/issues/795
155+ r := def.runs()
156+ var target string
157+ switch s.stage {
158+ case stageMain:
159+ target = r.Main
160+ case stagePre:
161+ target = r.Pre
162+ case stagePost:
163+ target = r.Post
164+ }
165+ fail := func(err error) stepResult { j.errorf(num, "%s", err.Error()); return resFailed }
166+ if target == "" {
167+ return fail(fmt.Errorf("Value cannot be null or empty. (Parameter 'target')"))
168+ }
169+ script := filepath.Join(def.dir, filepath.FromSlash(target))
170+ if st, err := os.Stat(script); err != nil || st.IsDir() {
171+ return fail(fmt.Errorf("File not found: '%s'", script))
172+ }
173+ j.mu.Lock()
174+ wd := j.state.GitHub("workspace")
175+ j.mu.Unlock()
176+ node := filepath.Join(j.externalsDir(), s.nodeVersion, "bin", "node")
177+ s.actEnv = env
178+ return j.runProc(s, node, wd, []string{node, script}, "Node Action run completed with exit code %d", cancel)
179+}
A internal/executor/actions_prepare.go +305 -0
@@ -0,0 +1,305 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "context"
7+ "fmt"
8+ "strings"
9+
10+ "bnema.dev/ermite-actions/action"
11+)
12+
13+// actionState is what the pre-job phase leaves for the run.
14+type actionState struct {
15+ nodes []*node
16+ // pulled and built map an image to what the setup steps produced.
17+ pulled map[string]string // image -> image
18+ built map[string]string // action repository -> tag
19+ // setup are the steps that pull and build images, in the order of
20+ // ActionManager.PrepareActionsAsync (pulls, then builds).
21+ pullOrder, buildOrder []string
22+ buildInfo map[string]buildInfo
23+ pullUsers map[string][]*node
24+ buildUsers map[string][]*node
25+ // pre are the pre steps of the job's steps with a pre stage, by node.
26+ pre map[*node]bool
27+ // resolved caches the download info by "name@ref" (ActionManager's legacy
28+ // path asks again at every level; ermite's server caches per run).
29+ downloaded map[string]bool
30+}
31+
32+type buildInfo struct{ repository, dockerfile, workDir string }
33+
34+func newActionState() *actionState {
35+ return &actionState{
36+ pulled: map[string]string{}, built: map[string]string{}, buildInfo: map[string]buildInfo{},
37+ pullUsers: map[string][]*node{}, buildUsers: map[string][]*node{}, pre: map[*node]bool{},
38+ downloaded: map[string]bool{},
39+ }
40+}
41+
42+// lookupKey is ActionManager.GetDownloadInfoLookupKey: "Name@Ref" for a
43+// repository action, "" for a local one.
44+func lookupKey(n *node) (string, error) {
45+ switch n.uses.Kind {
46+ case action.UsesRepository:
47+ return n.uses.Name + "@" + n.uses.Ref, nil
48+ case action.UsesSelfRepository:
49+ return "", ghErrorf("Unable to resolve self-reference '$/'. This can occur when the server does not support this syntax, the feature flag is disabled, or the workflow context (repository/SHA) is unavailable.")
50+ }
51+ return "", nil
52+}
53+
54+// prepare is ActionManager.PrepareActionsRecursiveLegacyAsync
55+// (ActionManager.cs:439-654): resolve and download the repository actions of
56+// nodes, read their manifests, recurse into composite actions, and note the
57+// images to pull or build and the pre and post stages. depth is 0 for the
58+// steps of the job and 1 for the ones of a local composite action prepared when
59+// it runs.
60+func (j *job) prepare(ctx context.Context, num int, nodes []*node, depth int, parent *node) error {
61+ if depth > compositeMaxDepth {
62+ return fmt.Errorf("Composite action depth exceeded max depth %d", compositeMaxDepth)
63+ }
64+ st := j.actions
65+ var repoNodes []*node
66+ for _, n := range nodes {
67+ switch n.uses.Kind {
68+ case action.UsesDocker:
69+ if n.uses.Image == "" {
70+ return fmt.Errorf("Value cannot be null or empty. (Parameter 'Image')")
71+ }
72+ if _, ok := st.pullUsers[n.uses.Image]; !ok {
73+ st.pullOrder = append(st.pullOrder, n.uses.Image)
74+ }
75+ st.pullUsers[n.uses.Image] = append(st.pullUsers[n.uses.Image], n)
76+ case action.UsesRepository, action.UsesLocal, action.UsesSelfRepository:
77+ repoNodes = append(repoNodes, n)
78+ }
79+ }
80+ if len(repoNodes) == 0 {
81+ return nil
82+ }
83+ infos, err := j.downloadInfo(ctx, num, repoNodes)
84+ if err != nil {
85+ return err
86+ }
87+ for _, n := range repoNodes {
88+ key, err := lookupKey(n)
89+ if err != nil {
90+ return err
91+ }
92+ if key == "" {
93+ continue
94+ }
95+ ra, ok := infos[key]
96+ if !ok {
97+ return fmt.Errorf("Missing download info for %s", key)
98+ }
99+ dir := j.actionDirOfRepo(n)
100+ // The directory of the archive is the one of the repository itself: the
101+ // action's path is below it.
102+ root := j.state.Layout().Actions + "/" + n.uses.Name + "/" + n.uses.Ref
103+ _ = dir
104+ if err := j.downloadAction(ctx, num, ra, ra.NameWithOwner, n.uses.Ref, root); err != nil {
105+ return err
106+ }
107+ }
108+ for _, n := range repoNodes {
109+ if err := j.prepareRepositoryAction(ctx, num, n, depth); err != nil {
110+ return err
111+ }
112+ }
113+ return nil
114+}
115+
116+func (j *job) actionDirOfRepo(n *node) string { return j.actionDir(n) }
117+
118+// downloadInfo is ActionManager.GetDownloadInfoAsync (ActionManager.cs:1000-1135):
119+// it asks the server, through the host, about the distinct repository actions
120+// of nodes. The server's answer for one action may be an error in GitHub's
121+// words, which is the message of the failure.
122+func (j *job) downloadInfo(ctx context.Context, num int, nodes []*node) (map[string]ResolvedAction, error) {
123+ j.out(num, "Getting action download info")
124+ var uses, keys []string
125+ seen := map[string]bool{}
126+ for _, n := range nodes {
127+ key, err := lookupKey(n)
128+ if err != nil {
129+ return nil, err
130+ }
131+ if key == "" || seen[key] {
132+ continue
133+ }
134+ seen[key] = true
135+ keys = append(keys, key)
136+ uses = append(uses, refString(n.uses))
137+ }
138+ out := map[string]ResolvedAction{}
139+ if len(uses) == 0 {
140+ return out, nil
141+ }
142+ if j.cfg.Actions.Resolver == nil {
143+ return nil, ghErrorf("Failed to resolve action download info.")
144+ }
145+ res, err := j.cfg.Actions.Resolver(ctx, uses)
146+ if err != nil {
147+ return nil, ghErrorf("%s", err.Error())
148+ }
149+ if len(res) != len(uses) {
150+ return nil, ghErrorf("Failed to resolve action download info.")
151+ }
152+ for i, ra := range res {
153+ if ra.Error != "" {
154+ return nil, ghErrorf("%s", ra.Error)
155+ }
156+ // The token is a secret from here on (ActionManager.cs:1130).
157+ j.addTokenSecret(ra.Token)
158+ if ra.NameWithOwner == "" {
159+ ra.NameWithOwner = strings.SplitN(keys[i], "@", 2)[0]
160+ }
161+ out[keys[i]] = ra
162+ }
163+ return out, nil
164+}
165+
166+// prepareRepositoryAction is ActionManager.PrepareRepositoryActionAsync
167+// (:1411-1550) and the code after it in the loop of the caller: read the
168+// manifest, note an image to pull or build, recurse into a composite, and
169+// register pre and post stages.
170+func (j *job) prepareRepositoryAction(ctx context.Context, num int, n *node, depth int) error {
171+ local := n.uses.Kind == action.UsesLocal
172+ if local {
173+ if depth > 0 {
174+ // "if we're in a composite action and haven't loaded the local action
175+ // yet we assume it has a post step" (ActionManager.cs:643-654).
176+ n.parentPost()
177+ }
178+ return nil
179+ }
180+ dir := j.actionDir(n)
181+ def, err := j.loadDef(num, dir)
182+ if err == errNoAction {
183+ return fmt.Errorf("Can't find 'action.yml', 'action.yaml' or 'Dockerfile' for action '%s'.", refString(n.uses))
184+ }
185+ if err != nil {
186+ return err
187+ }
188+ n.def = def
189+ repo := n.uses.Name + "@" + n.uses.Ref
190+ if n.uses.Path != "" {
191+ repo = n.uses.Name + "/" + n.uses.Path + "@" + n.uses.Ref
192+ }
193+ root := j.state.Layout().Actions + "/" + n.uses.Name + "/" + n.uses.Ref
194+ st := j.actions
195+ switch def.kind {
196+ case kindDocker:
197+ img, build, err := j.dockerImageOf(def)
198+ if err != nil {
199+ return err
200+ }
201+ if build != "" {
202+ if _, ok := st.buildUsers[repo]; !ok {
203+ st.buildOrder = append(st.buildOrder, repo)
204+ }
205+ st.buildUsers[repo] = append(st.buildUsers[repo], n)
206+ st.buildInfo[repo] = buildInfo{repository: repo, dockerfile: build, workDir: root}
207+ } else {
208+ if _, ok := st.pullUsers[img]; !ok {
209+ st.pullOrder = append(st.pullOrder, img)
210+ }
211+ st.pullUsers[img] = append(st.pullUsers[img], n)
212+ }
213+ case kindComposite:
214+ if err := j.prepareComposite(ctx, num, n, depth); err != nil {
215+ return err
216+ }
217+ }
218+ j.registerStages(n, depth)
219+ return nil
220+}
221+
222+// dockerImageOf says what a container action needs: an image to pull, or a
223+// Dockerfile to build (absolute path).
224+func (j *job) dockerImageOf(def *actionDef) (pull, build string, err error) {
225+ if def.act == nil {
226+ return "", def.dockerfile, nil
227+ }
228+ im, err := action.ParseImage(def.image)
229+ if err != nil {
230+ return "", "", err
231+ }
232+ if im.Kind == action.ImageDockerfile {
233+ return "", def.dir + "/" + im.Path, nil
234+ }
235+ return im.Ref, "", nil
236+}
237+
238+// prepareComposite creates the nodes of a composite action's steps and prepares
239+// them one level down.
240+func (j *job) prepareComposite(ctx context.Context, num int, n *node, depth int) error {
241+ steps := n.def.act.Runs.Steps
242+ kids, err := newNodes(steps, n, n.scope)
243+ if err != nil {
244+ return err
245+ }
246+ n.children = kids
247+ return j.prepare(ctx, num, kids, depth+1, n)
248+}
249+
250+// registerStages notes the pre and post stages of a prepared action that is not
251+// local (ActionManager.cs:600-640): for the steps of the job a pre step, for
252+// the steps of a composite action a pre and post step of the composite.
253+func (j *job) registerStages(n *node, depth int) {
254+ pre, post := n.hasPre(), n.hasPost()
255+ if depth < 1 {
256+ if pre {
257+ j.actions.pre[n] = true
258+ }
259+ return
260+ }
261+ if p := n.parent; p != nil {
262+ if pre {
263+ p.preSteps = append(p.preSteps, n)
264+ }
265+ if post {
266+ p.postSteps = append(p.postSteps, n)
267+ }
268+ }
269+}
270+
271+// parentPost is the assumption for a local action inside a composite action.
272+func (n *node) parentPost() {
273+ if p := n.parent; p != nil {
274+ p.postSteps = append(p.postSteps, n)
275+ }
276+}
277+
278+// hasPre and hasPost are ActionExecutionData.HasPre and HasPost.
279+func (n *node) hasPre() bool {
280+ d := n.def
281+ if d == nil {
282+ return false
283+ }
284+ if d.kind == kindComposite {
285+ return len(n.preSteps) > 0
286+ }
287+ if r := d.runs(); r != nil {
288+ return r.HasPre()
289+ }
290+ return false
291+}
292+
293+func (n *node) hasPost() bool {
294+ d := n.def
295+ if d == nil {
296+ return false
297+ }
298+ if d.kind == kindComposite {
299+ return len(n.postSteps) > 0
300+ }
301+ if r := d.runs(); r != nil {
302+ return r.HasPost()
303+ }
304+ return false
305+}
@@ -0,0 +1,305 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "context"
7+ "fmt"
8+ "strings"
9+
10+ "bnema.dev/ermite-actions/action"
11+)
12+
13+// actionState is what the pre-job phase leaves for the run.
14+type actionState struct {
15+ nodes []*node
16+ // pulled and built map an image to what the setup steps produced.
17+ pulled map[string]string // image -> image
18+ built map[string]string // action repository -> tag
19+ // setup are the steps that pull and build images, in the order of
20+ // ActionManager.PrepareActionsAsync (pulls, then builds).
21+ pullOrder, buildOrder []string
22+ buildInfo map[string]buildInfo
23+ pullUsers map[string][]*node
24+ buildUsers map[string][]*node
25+ // pre are the pre steps of the job's steps with a pre stage, by node.
26+ pre map[*node]bool
27+ // resolved caches the download info by "name@ref" (ActionManager's legacy
28+ // path asks again at every level; ermite's server caches per run).
29+ downloaded map[string]bool
30+}
31+
32+type buildInfo struct{ repository, dockerfile, workDir string }
33+
34+func newActionState() *actionState {
35+ return &actionState{
36+ pulled: map[string]string{}, built: map[string]string{}, buildInfo: map[string]buildInfo{},
37+ pullUsers: map[string][]*node{}, buildUsers: map[string][]*node{}, pre: map[*node]bool{},
38+ downloaded: map[string]bool{},
39+ }
40+}
41+
42+// lookupKey is ActionManager.GetDownloadInfoLookupKey: "Name@Ref" for a
43+// repository action, "" for a local one.
44+func lookupKey(n *node) (string, error) {
45+ switch n.uses.Kind {
46+ case action.UsesRepository:
47+ return n.uses.Name + "@" + n.uses.Ref, nil
48+ case action.UsesSelfRepository:
49+ return "", ghErrorf("Unable to resolve self-reference '$/'. This can occur when the server does not support this syntax, the feature flag is disabled, or the workflow context (repository/SHA) is unavailable.")
50+ }
51+ return "", nil
52+}
53+
54+// prepare is ActionManager.PrepareActionsRecursiveLegacyAsync
55+// (ActionManager.cs:439-654): resolve and download the repository actions of
56+// nodes, read their manifests, recurse into composite actions, and note the
57+// images to pull or build and the pre and post stages. depth is 0 for the
58+// steps of the job and 1 for the ones of a local composite action prepared when
59+// it runs.
60+func (j *job) prepare(ctx context.Context, num int, nodes []*node, depth int, parent *node) error {
61+ if depth > compositeMaxDepth {
62+ return fmt.Errorf("Composite action depth exceeded max depth %d", compositeMaxDepth)
63+ }
64+ st := j.actions
65+ var repoNodes []*node
66+ for _, n := range nodes {
67+ switch n.uses.Kind {
68+ case action.UsesDocker:
69+ if n.uses.Image == "" {
70+ return fmt.Errorf("Value cannot be null or empty. (Parameter 'Image')")
71+ }
72+ if _, ok := st.pullUsers[n.uses.Image]; !ok {
73+ st.pullOrder = append(st.pullOrder, n.uses.Image)
74+ }
75+ st.pullUsers[n.uses.Image] = append(st.pullUsers[n.uses.Image], n)
76+ case action.UsesRepository, action.UsesLocal, action.UsesSelfRepository:
77+ repoNodes = append(repoNodes, n)
78+ }
79+ }
80+ if len(repoNodes) == 0 {
81+ return nil
82+ }
83+ infos, err := j.downloadInfo(ctx, num, repoNodes)
84+ if err != nil {
85+ return err
86+ }
87+ for _, n := range repoNodes {
88+ key, err := lookupKey(n)
89+ if err != nil {
90+ return err
91+ }
92+ if key == "" {
93+ continue
94+ }
95+ ra, ok := infos[key]
96+ if !ok {
97+ return fmt.Errorf("Missing download info for %s", key)
98+ }
99+ dir := j.actionDirOfRepo(n)
100+ // The directory of the archive is the one of the repository itself: the
101+ // action's path is below it.
102+ root := j.state.Layout().Actions + "/" + n.uses.Name + "/" + n.uses.Ref
103+ _ = dir
104+ if err := j.downloadAction(ctx, num, ra, ra.NameWithOwner, n.uses.Ref, root); err != nil {
105+ return err
106+ }
107+ }
108+ for _, n := range repoNodes {
109+ if err := j.prepareRepositoryAction(ctx, num, n, depth); err != nil {
110+ return err
111+ }
112+ }
113+ return nil
114+}
115+
116+func (j *job) actionDirOfRepo(n *node) string { return j.actionDir(n) }
117+
118+// downloadInfo is ActionManager.GetDownloadInfoAsync (ActionManager.cs:1000-1135):
119+// it asks the server, through the host, about the distinct repository actions
120+// of nodes. The server's answer for one action may be an error in GitHub's
121+// words, which is the message of the failure.
122+func (j *job) downloadInfo(ctx context.Context, num int, nodes []*node) (map[string]ResolvedAction, error) {
123+ j.out(num, "Getting action download info")
124+ var uses, keys []string
125+ seen := map[string]bool{}
126+ for _, n := range nodes {
127+ key, err := lookupKey(n)
128+ if err != nil {
129+ return nil, err
130+ }
131+ if key == "" || seen[key] {
132+ continue
133+ }
134+ seen[key] = true
135+ keys = append(keys, key)
136+ uses = append(uses, refString(n.uses))
137+ }
138+ out := map[string]ResolvedAction{}
139+ if len(uses) == 0 {
140+ return out, nil
141+ }
142+ if j.cfg.Actions.Resolver == nil {
143+ return nil, ghErrorf("Failed to resolve action download info.")
144+ }
145+ res, err := j.cfg.Actions.Resolver(ctx, uses)
146+ if err != nil {
147+ return nil, ghErrorf("%s", err.Error())
148+ }
149+ if len(res) != len(uses) {
150+ return nil, ghErrorf("Failed to resolve action download info.")
151+ }
152+ for i, ra := range res {
153+ if ra.Error != "" {
154+ return nil, ghErrorf("%s", ra.Error)
155+ }
156+ // The token is a secret from here on (ActionManager.cs:1130).
157+ j.addTokenSecret(ra.Token)
158+ if ra.NameWithOwner == "" {
159+ ra.NameWithOwner = strings.SplitN(keys[i], "@", 2)[0]
160+ }
161+ out[keys[i]] = ra
162+ }
163+ return out, nil
164+}
165+
166+// prepareRepositoryAction is ActionManager.PrepareRepositoryActionAsync
167+// (:1411-1550) and the code after it in the loop of the caller: read the
168+// manifest, note an image to pull or build, recurse into a composite, and
169+// register pre and post stages.
170+func (j *job) prepareRepositoryAction(ctx context.Context, num int, n *node, depth int) error {
171+ local := n.uses.Kind == action.UsesLocal
172+ if local {
173+ if depth > 0 {
174+ // "if we're in a composite action and haven't loaded the local action
175+ // yet we assume it has a post step" (ActionManager.cs:643-654).
176+ n.parentPost()
177+ }
178+ return nil
179+ }
180+ dir := j.actionDir(n)
181+ def, err := j.loadDef(num, dir)
182+ if err == errNoAction {
183+ return fmt.Errorf("Can't find 'action.yml', 'action.yaml' or 'Dockerfile' for action '%s'.", refString(n.uses))
184+ }
185+ if err != nil {
186+ return err
187+ }
188+ n.def = def
189+ repo := n.uses.Name + "@" + n.uses.Ref
190+ if n.uses.Path != "" {
191+ repo = n.uses.Name + "/" + n.uses.Path + "@" + n.uses.Ref
192+ }
193+ root := j.state.Layout().Actions + "/" + n.uses.Name + "/" + n.uses.Ref
194+ st := j.actions
195+ switch def.kind {
196+ case kindDocker:
197+ img, build, err := j.dockerImageOf(def)
198+ if err != nil {
199+ return err
200+ }
201+ if build != "" {
202+ if _, ok := st.buildUsers[repo]; !ok {
203+ st.buildOrder = append(st.buildOrder, repo)
204+ }
205+ st.buildUsers[repo] = append(st.buildUsers[repo], n)
206+ st.buildInfo[repo] = buildInfo{repository: repo, dockerfile: build, workDir: root}
207+ } else {
208+ if _, ok := st.pullUsers[img]; !ok {
209+ st.pullOrder = append(st.pullOrder, img)
210+ }
211+ st.pullUsers[img] = append(st.pullUsers[img], n)
212+ }
213+ case kindComposite:
214+ if err := j.prepareComposite(ctx, num, n, depth); err != nil {
215+ return err
216+ }
217+ }
218+ j.registerStages(n, depth)
219+ return nil
220+}
221+
222+// dockerImageOf says what a container action needs: an image to pull, or a
223+// Dockerfile to build (absolute path).
224+func (j *job) dockerImageOf(def *actionDef) (pull, build string, err error) {
225+ if def.act == nil {
226+ return "", def.dockerfile, nil
227+ }
228+ im, err := action.ParseImage(def.image)
229+ if err != nil {
230+ return "", "", err
231+ }
232+ if im.Kind == action.ImageDockerfile {
233+ return "", def.dir + "/" + im.Path, nil
234+ }
235+ return im.Ref, "", nil
236+}
237+
238+// prepareComposite creates the nodes of a composite action's steps and prepares
239+// them one level down.
240+func (j *job) prepareComposite(ctx context.Context, num int, n *node, depth int) error {
241+ steps := n.def.act.Runs.Steps
242+ kids, err := newNodes(steps, n, n.scope)
243+ if err != nil {
244+ return err
245+ }
246+ n.children = kids
247+ return j.prepare(ctx, num, kids, depth+1, n)
248+}
249+
250+// registerStages notes the pre and post stages of a prepared action that is not
251+// local (ActionManager.cs:600-640): for the steps of the job a pre step, for
252+// the steps of a composite action a pre and post step of the composite.
253+func (j *job) registerStages(n *node, depth int) {
254+ pre, post := n.hasPre(), n.hasPost()
255+ if depth < 1 {
256+ if pre {
257+ j.actions.pre[n] = true
258+ }
259+ return
260+ }
261+ if p := n.parent; p != nil {
262+ if pre {
263+ p.preSteps = append(p.preSteps, n)
264+ }
265+ if post {
266+ p.postSteps = append(p.postSteps, n)
267+ }
268+ }
269+}
270+
271+// parentPost is the assumption for a local action inside a composite action.
272+func (n *node) parentPost() {
273+ if p := n.parent; p != nil {
274+ p.postSteps = append(p.postSteps, n)
275+ }
276+}
277+
278+// hasPre and hasPost are ActionExecutionData.HasPre and HasPost.
279+func (n *node) hasPre() bool {
280+ d := n.def
281+ if d == nil {
282+ return false
283+ }
284+ if d.kind == kindComposite {
285+ return len(n.preSteps) > 0
286+ }
287+ if r := d.runs(); r != nil {
288+ return r.HasPre()
289+ }
290+ return false
291+}
292+
293+func (n *node) hasPost() bool {
294+ d := n.def
295+ if d == nil {
296+ return false
297+ }
298+ if d.kind == kindComposite {
299+ return len(n.postSteps) > 0
300+ }
301+ if r := d.runs(); r != nil {
302+ return r.HasPost()
303+ }
304+ return false
305+}
A internal/executor/actions_run.go +223 -0
@@ -0,0 +1,223 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "fmt"
7+ "path/filepath"
8+ "strings"
9+
10+ "bnema.dev/ermite-actions/action"
11+)
12+
13+// stageName is for messages.
14+func (s stage) String() string {
15+ switch s {
16+ case stagePre:
17+ return "Pre"
18+ case stagePost:
19+ return "Post"
20+ }
21+ return "Main"
22+}
23+
24+// definition is ActionManager.LoadAction for the step: the manifest of a local
25+// action is read when the step runs; the other actions were loaded by the
26+// pre-job phase.
27+func (j *job) definition(s *stepRun) (*actionDef, error) {
28+ n := s.n
29+ switch n.uses.Kind {
30+ case action.UsesDocker:
31+ return &actionDef{kind: kindDocker, image: n.uses.Image}, nil
32+ case action.UsesLocal:
33+ dir := j.actionDir(n)
34+ def, err := j.loadDef(s.num, dir)
35+ if err == errNoAction {
36+ return nil, fmt.Errorf("Can't find 'action.yml', 'action.yaml' or 'Dockerfile' under '%s'. Did you forget to run actions/checkout before running your local action?", filepath.Clean(dir))
37+ }
38+ if err != nil {
39+ return nil, err
40+ }
41+ n.def = def
42+ return def, nil
43+ }
44+ if n.def == nil {
45+ return nil, fmt.Errorf("The action '%s' was not prepared", refString(n.uses))
46+ }
47+ return n.def, nil
48+}
49+
50+// setActionRepository sets github.action_repository and action_ref
51+// (ActionRunner.cs:149-165): the repository and ref of a repository action, empty
52+// for anything else.
53+func (j *job) setActionRepository(n *node) {
54+ repo, ref := "", ""
55+ if n != nil && n.uses.Kind == action.UsesRepository {
56+ repo, ref = n.uses.Name, n.uses.Ref
57+ }
58+ j.mu.Lock()
59+ j.state.SetGitHub("action_repository", repo)
60+ j.state.SetGitHub("action_ref", ref)
61+ j.mu.Unlock()
62+}
63+
64+// registerPost registers the post stage of the action (ActionRunner.cs:99-126,
65+// ExecutionContext.RegisterPostJobStep): a step "Post <name>" on the stack the
66+// job runs after its steps, last registered first; inside a composite action
67+// only the fact is recorded, the composite's own post runs the step.
68+func (j *job) registerPost(s *stepRun, def *actionDef) {
69+ n := s.n
70+ name := s.displayName
71+ if s.stage == stagePre && strings.HasPrefix(strings.ToLower(name), "pre ") {
72+ name = name[len("Pre "):]
73+ }
74+ j.debug(s.num, "Register post job cleanup for action: "+registeredRef(n.uses))
75+ if s.parent != nil {
76+ n.postRegistered, n.postCond = true, def.postIf()
77+ return
78+ }
79+ if n.postRegistered {
80+ return // StepsWithPostRegistered
81+ }
82+ n.postRegistered = true
83+ j.mu.Lock()
84+ j.postStack = append(j.postStack, qstep{n: n, stage: stagePost, name: "Post " + name, cond: def.postIf()})
85+ j.mu.Unlock()
86+}
87+
88+// registeredRef is the text of the debug line of the registration.
89+func registeredRef(u action.Uses) string {
90+ path := ""
91+ if u.Path != "" {
92+ path = "/" + u.Path
93+ }
94+ if u.Ref == "" {
95+ return u.Name + path
96+ }
97+ return u.Name + path + "@" + u.Ref
98+}
99+
100+// handlerGroup is Handler.PrintActionDetails's group name.
101+func handlerGroup(u action.Uses) string {
102+ switch u.Kind {
103+ case action.UsesDocker:
104+ return "Run docker://" + u.Image
105+ case action.UsesLocal:
106+ return "Run " + u.Path
107+ }
108+ if u.Path == "" {
109+ return fmt.Sprintf("Run %s@%s", u.Name, u.Ref)
110+ }
111+ return fmt.Sprintf("Run %s/%s@%s", u.Name, u.Path, u.Ref)
112+}
113+
114+// printDetails is Handler.PrintActionDetails (Handler.cs:102-165): the group
115+// of the step with its inputs that are not empty and its environment. The post
116+// stage only prints "Post job cleanup.".
117+func (j *job) printDetails(s *stepRun, in *inputSet, env *ordered) {
118+ num := s.num
119+ if s.stage == stagePost {
120+ j.out(num, "Post job cleanup.")
121+ return
122+ }
123+ j.out(num, "##[group]"+handlerGroup(s.n.uses))
124+ if in.Len() > 0 {
125+ j.out(num, "with:")
126+ for _, k := range in.keys {
127+ if v, _ := in.Get(k); v != "" {
128+ j.out(num, fmt.Sprintf(" %s: %s", k, v))
129+ }
130+ }
131+ }
132+ if env.Len() > 0 {
133+ j.out(num, "env:")
134+ for _, k := range env.Keys() {
135+ v, _ := env.Get(k)
136+ j.out(num, fmt.Sprintf(" %s: %s", k, v))
137+ }
138+ }
139+ j.out(num, "##[endgroup]")
140+}
141+
142+// runAction is ActionRunner.RunAsync (ActionRunner.cs:62-247) for a uses step
143+// in any stage: load the action, evaluate the inputs and the environment, print
144+// the details and run the handler of the action.
145+func (j *job) runAction(s *stepRun, cancel <-chan struct{}) stepResult {
146+ num, n := s.num, s.n
147+ fail := func(err error) stepResult { j.errorf(num, "%s", err.Error()); return resFailed }
148+ def, err := j.definition(s)
149+ if err != nil {
150+ return fail(err)
151+ }
152+ s.def = def
153+ local := n.uses.Kind == action.UsesLocal
154+ // A local composite action is expanded when it runs (ActionRunner.cs:77-96).
155+ if def.kind == kindComposite && s.stage == stageMain && local {
156+ if err := j.prepareLocalComposite(s, def); err != nil {
157+ return fail(err)
158+ }
159+ }
160+ if local && n.hasPre() {
161+ j.tagged(num, "##[warning]", fmt.Sprintf("`pre` execution is not supported for local action from '%s'", n.uses.Path))
162+ }
163+ if n.hasPostOf(def) && s.stage != stagePost {
164+ j.registerPost(s, def)
165+ }
166+ j.setActionRepository(n)
167+ if err := j.prepareFileCommands(s); err != nil {
168+ return fail(err)
169+ }
170+ defer s.fc.Process(s)
171+
172+ j.debug(num, "Loading inputs")
173+ in, err := j.evalInputs(s.st, s.env)
174+ if err != nil {
175+ j.errorf(num, "%s", templateNotValid(err, true))
176+ return resFailed
177+ }
178+ if err := j.mergeInputs(s, def, in); err != nil {
179+ j.errorf(num, "%s", templateNotValid(err, true))
180+ return resFailed
181+ }
182+ j.debug(num, "Loading env")
183+ env := s.env.Clone()
184+ for _, k := range n.state.keysOrNil() {
185+ v, _ := n.state.Get(k)
186+ env.Set("STATE_"+k, v)
187+ }
188+ s.actEnv = env
189+ if err := j.nodeVersionFor(s, def); err != nil {
190+ return fail(err)
191+ }
192+ j.printDetails(s, in, env)
193+ switch def.kind {
194+ case kindNode:
195+ return j.runNode(s, def, in, env, cancel)
196+ case kindDocker:
197+ return j.runDocker(s, def, in, env, cancel)
198+ case kindComposite:
199+ return j.runComposite(s, def, in, env)
200+ }
201+ return fail(fmt.Errorf("Unsupported action type: %v", def.kind))
202+}
203+
204+// hasPostOf is HasPost of the loaded definition of the node.
205+func (n *node) hasPostOf(def *actionDef) bool {
206+ switch def.kind {
207+ case kindComposite:
208+ return len(n.postSteps) > 0
209+ case kindScript:
210+ return false
211+ }
212+ if r := def.runs(); r != nil {
213+ return r.HasPost()
214+ }
215+ return false
216+}
217+
218+func (o *ordered) keysOrNil() []string {
219+ if o == nil {
220+ return nil
221+ }
222+ return o.Keys()
223+}
@@ -0,0 +1,223 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "fmt"
7+ "path/filepath"
8+ "strings"
9+
10+ "bnema.dev/ermite-actions/action"
11+)
12+
13+// stageName is for messages.
14+func (s stage) String() string {
15+ switch s {
16+ case stagePre:
17+ return "Pre"
18+ case stagePost:
19+ return "Post"
20+ }
21+ return "Main"
22+}
23+
24+// definition is ActionManager.LoadAction for the step: the manifest of a local
25+// action is read when the step runs; the other actions were loaded by the
26+// pre-job phase.
27+func (j *job) definition(s *stepRun) (*actionDef, error) {
28+ n := s.n
29+ switch n.uses.Kind {
30+ case action.UsesDocker:
31+ return &actionDef{kind: kindDocker, image: n.uses.Image}, nil
32+ case action.UsesLocal:
33+ dir := j.actionDir(n)
34+ def, err := j.loadDef(s.num, dir)
35+ if err == errNoAction {
36+ return nil, fmt.Errorf("Can't find 'action.yml', 'action.yaml' or 'Dockerfile' under '%s'. Did you forget to run actions/checkout before running your local action?", filepath.Clean(dir))
37+ }
38+ if err != nil {
39+ return nil, err
40+ }
41+ n.def = def
42+ return def, nil
43+ }
44+ if n.def == nil {
45+ return nil, fmt.Errorf("The action '%s' was not prepared", refString(n.uses))
46+ }
47+ return n.def, nil
48+}
49+
50+// setActionRepository sets github.action_repository and action_ref
51+// (ActionRunner.cs:149-165): the repository and ref of a repository action, empty
52+// for anything else.
53+func (j *job) setActionRepository(n *node) {
54+ repo, ref := "", ""
55+ if n != nil && n.uses.Kind == action.UsesRepository {
56+ repo, ref = n.uses.Name, n.uses.Ref
57+ }
58+ j.mu.Lock()
59+ j.state.SetGitHub("action_repository", repo)
60+ j.state.SetGitHub("action_ref", ref)
61+ j.mu.Unlock()
62+}
63+
64+// registerPost registers the post stage of the action (ActionRunner.cs:99-126,
65+// ExecutionContext.RegisterPostJobStep): a step "Post <name>" on the stack the
66+// job runs after its steps, last registered first; inside a composite action
67+// only the fact is recorded, the composite's own post runs the step.
68+func (j *job) registerPost(s *stepRun, def *actionDef) {
69+ n := s.n
70+ name := s.displayName
71+ if s.stage == stagePre && strings.HasPrefix(strings.ToLower(name), "pre ") {
72+ name = name[len("Pre "):]
73+ }
74+ j.debug(s.num, "Register post job cleanup for action: "+registeredRef(n.uses))
75+ if s.parent != nil {
76+ n.postRegistered, n.postCond = true, def.postIf()
77+ return
78+ }
79+ if n.postRegistered {
80+ return // StepsWithPostRegistered
81+ }
82+ n.postRegistered = true
83+ j.mu.Lock()
84+ j.postStack = append(j.postStack, qstep{n: n, stage: stagePost, name: "Post " + name, cond: def.postIf()})
85+ j.mu.Unlock()
86+}
87+
88+// registeredRef is the text of the debug line of the registration.
89+func registeredRef(u action.Uses) string {
90+ path := ""
91+ if u.Path != "" {
92+ path = "/" + u.Path
93+ }
94+ if u.Ref == "" {
95+ return u.Name + path
96+ }
97+ return u.Name + path + "@" + u.Ref
98+}
99+
100+// handlerGroup is Handler.PrintActionDetails's group name.
101+func handlerGroup(u action.Uses) string {
102+ switch u.Kind {
103+ case action.UsesDocker:
104+ return "Run docker://" + u.Image
105+ case action.UsesLocal:
106+ return "Run " + u.Path
107+ }
108+ if u.Path == "" {
109+ return fmt.Sprintf("Run %s@%s", u.Name, u.Ref)
110+ }
111+ return fmt.Sprintf("Run %s/%s@%s", u.Name, u.Path, u.Ref)
112+}
113+
114+// printDetails is Handler.PrintActionDetails (Handler.cs:102-165): the group
115+// of the step with its inputs that are not empty and its environment. The post
116+// stage only prints "Post job cleanup.".
117+func (j *job) printDetails(s *stepRun, in *inputSet, env *ordered) {
118+ num := s.num
119+ if s.stage == stagePost {
120+ j.out(num, "Post job cleanup.")
121+ return
122+ }
123+ j.out(num, "##[group]"+handlerGroup(s.n.uses))
124+ if in.Len() > 0 {
125+ j.out(num, "with:")
126+ for _, k := range in.keys {
127+ if v, _ := in.Get(k); v != "" {
128+ j.out(num, fmt.Sprintf(" %s: %s", k, v))
129+ }
130+ }
131+ }
132+ if env.Len() > 0 {
133+ j.out(num, "env:")
134+ for _, k := range env.Keys() {
135+ v, _ := env.Get(k)
136+ j.out(num, fmt.Sprintf(" %s: %s", k, v))
137+ }
138+ }
139+ j.out(num, "##[endgroup]")
140+}
141+
142+// runAction is ActionRunner.RunAsync (ActionRunner.cs:62-247) for a uses step
143+// in any stage: load the action, evaluate the inputs and the environment, print
144+// the details and run the handler of the action.
145+func (j *job) runAction(s *stepRun, cancel <-chan struct{}) stepResult {
146+ num, n := s.num, s.n
147+ fail := func(err error) stepResult { j.errorf(num, "%s", err.Error()); return resFailed }
148+ def, err := j.definition(s)
149+ if err != nil {
150+ return fail(err)
151+ }
152+ s.def = def
153+ local := n.uses.Kind == action.UsesLocal
154+ // A local composite action is expanded when it runs (ActionRunner.cs:77-96).
155+ if def.kind == kindComposite && s.stage == stageMain && local {
156+ if err := j.prepareLocalComposite(s, def); err != nil {
157+ return fail(err)
158+ }
159+ }
160+ if local && n.hasPre() {
161+ j.tagged(num, "##[warning]", fmt.Sprintf("`pre` execution is not supported for local action from '%s'", n.uses.Path))
162+ }
163+ if n.hasPostOf(def) && s.stage != stagePost {
164+ j.registerPost(s, def)
165+ }
166+ j.setActionRepository(n)
167+ if err := j.prepareFileCommands(s); err != nil {
168+ return fail(err)
169+ }
170+ defer s.fc.Process(s)
171+
172+ j.debug(num, "Loading inputs")
173+ in, err := j.evalInputs(s.st, s.env)
174+ if err != nil {
175+ j.errorf(num, "%s", templateNotValid(err, true))
176+ return resFailed
177+ }
178+ if err := j.mergeInputs(s, def, in); err != nil {
179+ j.errorf(num, "%s", templateNotValid(err, true))
180+ return resFailed
181+ }
182+ j.debug(num, "Loading env")
183+ env := s.env.Clone()
184+ for _, k := range n.state.keysOrNil() {
185+ v, _ := n.state.Get(k)
186+ env.Set("STATE_"+k, v)
187+ }
188+ s.actEnv = env
189+ if err := j.nodeVersionFor(s, def); err != nil {
190+ return fail(err)
191+ }
192+ j.printDetails(s, in, env)
193+ switch def.kind {
194+ case kindNode:
195+ return j.runNode(s, def, in, env, cancel)
196+ case kindDocker:
197+ return j.runDocker(s, def, in, env, cancel)
198+ case kindComposite:
199+ return j.runComposite(s, def, in, env)
200+ }
201+ return fail(fmt.Errorf("Unsupported action type: %v", def.kind))
202+}
203+
204+// hasPostOf is HasPost of the loaded definition of the node.
205+func (n *node) hasPostOf(def *actionDef) bool {
206+ switch def.kind {
207+ case kindComposite:
208+ return len(n.postSteps) > 0
209+ case kindScript:
210+ return false
211+ }
212+ if r := def.runs(); r != nil {
213+ return r.HasPost()
214+ }
215+ return false
216+}
217+
218+func (o *ordered) keysOrNil() []string {
219+ if o == nil {
220+ return nil
221+ }
222+ return o.Keys()
223+}
A internal/executor/actions_scope.go +83 -0
@@ -0,0 +1,83 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "strings"
7+
8+ "bnema.dev/ermite-actions/expr"
9+)
10+
11+// stepsScope is a steps context: the one of the job, or the one of a composite
12+// action's steps (StepsContext.GetScope in the runner), plus the inputs the
13+// steps of that composite see.
14+type stepsScope struct {
15+ order []string
16+ steps map[string]*stepCtx
17+ inputs *expr.Dictionary // nil: the job's inputs context
18+}
19+
20+func newScope() *stepsScope { return &stepsScope{steps: map[string]*stepCtx{}} }
21+
22+// PushScope makes the steps context called name (created empty on first use)
23+// the current one, with inputs as the inputs context, and returns the function
24+// that restores the previous one. A composite action's pre, main and post
25+// stages share one scope (the same name), as the runner's do.
26+func (s *State) PushScope(name string, inputs *expr.Dictionary) (restore func()) {
27+ prev := s.cur
28+ sc, ok := s.scopes[name]
29+ if name == "" {
30+ sc, ok = s.root, true
31+ }
32+ if !ok {
33+ sc = newScope()
34+ s.scopes[name] = sc
35+ }
36+ if inputs != nil {
37+ sc.inputs = inputs
38+ }
39+ s.cur = sc
40+ return func() { s.cur = prev }
41+}
42+
43+// StepsValue returns the steps context in use (outputs of a composite action
44+// are evaluated against it).
45+func (s *State) StepsValue() *expr.Dictionary { return s.stepsValue() }
46+
47+// GitHubKeys saves the current value of the given keys of the github context,
48+// to put them back with the returned function: a key that was absent is
49+// removed again. Composite actions' steps get their own copy of the context in
50+// the runner (a shallow copy, CompositeActionHandler.cs:152-161); the
51+// executor keeps one and restores it.
52+func (s *State) GitHubKeys(keys ...string) (restore func()) {
53+ type saved struct {
54+ key string
55+ val expr.Value
56+ has bool
57+ }
58+ var old []saved
59+ for _, k := range keys {
60+ v, has := s.github.Get(k)
61+ old = append(old, saved{k, v, has})
62+ }
63+ return func() {
64+ for _, o := range old {
65+ if o.has {
66+ s.github = setKey(s.github, o.key, o.val)
67+ } else {
68+ s.github = removeKey(s.github, o.key)
69+ }
70+ }
71+ }
72+}
73+
74+// removeKey returns d without key (compared case-insensitively).
75+func removeKey(d *expr.Dictionary, key string) *expr.Dictionary {
76+ out := expr.NewDictionary()
77+ for _, p := range d.Pairs() {
78+ if !strings.EqualFold(p.Key, key) {
79+ out.Add(p.Key, p.Value)
80+ }
81+ }
82+ return out
83+}
@@ -0,0 +1,83 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "strings"
7+
8+ "bnema.dev/ermite-actions/expr"
9+)
10+
11+// stepsScope is a steps context: the one of the job, or the one of a composite
12+// action's steps (StepsContext.GetScope in the runner), plus the inputs the
13+// steps of that composite see.
14+type stepsScope struct {
15+ order []string
16+ steps map[string]*stepCtx
17+ inputs *expr.Dictionary // nil: the job's inputs context
18+}
19+
20+func newScope() *stepsScope { return &stepsScope{steps: map[string]*stepCtx{}} }
21+
22+// PushScope makes the steps context called name (created empty on first use)
23+// the current one, with inputs as the inputs context, and returns the function
24+// that restores the previous one. A composite action's pre, main and post
25+// stages share one scope (the same name), as the runner's do.
26+func (s *State) PushScope(name string, inputs *expr.Dictionary) (restore func()) {
27+ prev := s.cur
28+ sc, ok := s.scopes[name]
29+ if name == "" {
30+ sc, ok = s.root, true
31+ }
32+ if !ok {
33+ sc = newScope()
34+ s.scopes[name] = sc
35+ }
36+ if inputs != nil {
37+ sc.inputs = inputs
38+ }
39+ s.cur = sc
40+ return func() { s.cur = prev }
41+}
42+
43+// StepsValue returns the steps context in use (outputs of a composite action
44+// are evaluated against it).
45+func (s *State) StepsValue() *expr.Dictionary { return s.stepsValue() }
46+
47+// GitHubKeys saves the current value of the given keys of the github context,
48+// to put them back with the returned function: a key that was absent is
49+// removed again. Composite actions' steps get their own copy of the context in
50+// the runner (a shallow copy, CompositeActionHandler.cs:152-161); the
51+// executor keeps one and restores it.
52+func (s *State) GitHubKeys(keys ...string) (restore func()) {
53+ type saved struct {
54+ key string
55+ val expr.Value
56+ has bool
57+ }
58+ var old []saved
59+ for _, k := range keys {
60+ v, has := s.github.Get(k)
61+ old = append(old, saved{k, v, has})
62+ }
63+ return func() {
64+ for _, o := range old {
65+ if o.has {
66+ s.github = setKey(s.github, o.key, o.val)
67+ } else {
68+ s.github = removeKey(s.github, o.key)
69+ }
70+ }
71+ }
72+}
73+
74+// removeKey returns d without key (compared case-insensitively).
75+func removeKey(d *expr.Dictionary, key string) *expr.Dictionary {
76+ out := expr.NewDictionary()
77+ for _, p := range d.Pairs() {
78+ if !strings.EqualFold(p.Key, key) {
79+ out.Add(p.Key, p.Value)
80+ }
81+ }
82+ return out
83+}
A internal/executor/actions_secrets.go +31 -0
@@ -0,0 +1,31 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "encoding/base64"
7+ "sync"
8+)
9+
10+// extraSecrets are the values the job registered in the masker after it
11+// started: the archive tokens of the actions (ActionManager.cs:1130,1650).
12+// Phase G's add-mask feeds the same masker; whoever merges second keeps both.
13+var extraSecretsMu sync.Mutex
14+
15+// addSecrets registers values with the masker, and the base64 of
16+// "x-access-token:<value>" for tokens (ActionManager.CreateAuthHeader).
17+func (j *job) addSecrets(values ...string) {
18+ extraSecretsMu.Lock()
19+ defer extraSecretsMu.Unlock()
20+ j.extraSecrets = append(j.extraSecrets, values...)
21+ all := append(j.state.Secrets(), j.extraSecrets...)
22+ j.masker.Store(newMasker(all...))
23+}
24+
25+// addTokenSecret registers an archive token and its Basic form.
26+func (j *job) addTokenSecret(token string) {
27+ if token == "" {
28+ return
29+ }
30+ j.addSecrets(token, base64.StdEncoding.EncodeToString([]byte("x-access-token:"+token)))
31+}
@@ -0,0 +1,31 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "encoding/base64"
7+ "sync"
8+)
9+
10+// extraSecrets are the values the job registered in the masker after it
11+// started: the archive tokens of the actions (ActionManager.cs:1130,1650).
12+// Phase G's add-mask feeds the same masker; whoever merges second keeps both.
13+var extraSecretsMu sync.Mutex
14+
15+// addSecrets registers values with the masker, and the base64 of
16+// "x-access-token:<value>" for tokens (ActionManager.CreateAuthHeader).
17+func (j *job) addSecrets(values ...string) {
18+ extraSecretsMu.Lock()
19+ defer extraSecretsMu.Unlock()
20+ j.extraSecrets = append(j.extraSecrets, values...)
21+ all := append(j.state.Secrets(), j.extraSecrets...)
22+ j.masker.Store(newMasker(all...))
23+}
24+
25+// addTokenSecret registers an archive token and its Basic form.
26+func (j *job) addTokenSecret(token string) {
27+ if token == "" {
28+ return
29+ }
30+ j.addSecrets(token, base64.StdEncoding.EncodeToString([]byte("x-access-token:"+token)))
31+}
A internal/executor/actions_setup.go +145 -0
@@ -0,0 +1,145 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "fmt"
7+ "os"
8+ "time"
9+)
10+
11+const (
12+ pullAttempts = 3
13+ buildAttempts = 3
14+)
15+
16+// setupSteps are the steps that pull and build the images of the actions, as
17+// JobExtensionRunner steps before the first one of the job
18+// (ActionManager.PrepareActionsAsync, ActionManager.cs:134-166): the pulls in
19+// the order the images were met, then the builds. They run if success().
20+func (j *job) setupSteps() []qstep {
21+ st := j.actions
22+ var out []qstep
23+ for _, img := range st.pullOrder {
24+ img, users := img, st.pullUsers[img]
25+ out = append(out, qstep{name: "Pull " + img, setup: func(s *stepRun, cancel <-chan struct{}) stepResult {
26+ return j.pullImage(s, img, users, cancel)
27+ }})
28+ }
29+ for _, repo := range st.buildOrder {
30+ users, info := st.buildUsers[repo], st.buildInfo[repo]
31+ out = append(out, qstep{name: "Build " + info.repository, setup: func(s *stepRun, cancel <-chan struct{}) stepResult {
32+ return j.buildActionImage(s, info, users, cancel)
33+ }})
34+ }
35+ return out
36+}
37+
38+// pullImage is ActionManager.PullActionContainerAsync (ActionManager.cs:803-846).
39+func (j *job) pullImage(s *stepRun, image string, users []*node, cancel <-chan struct{}) stepResult {
40+ num := s.num
41+ j.out(num, fmt.Sprintf("##[group]Pull down action image '%s'", image))
42+ code := 0
43+ for try := 1; try <= pullAttempts; try++ {
44+ c := j.newDockerCmd("pull", raw(image))
45+ var err error
46+ code, err = j.runDockerCmd(s, c, dirOrCwd(j.state.GitHub("workspace")), nil, cancel)
47+ if err == errCancelled {
48+ j.out(num, "##[endgroup]")
49+ return j.cancelledStep(num)
50+ }
51+ if err != nil {
52+ j.out(num, "##[endgroup]")
53+ j.errorf(num, "%s", err.Error())
54+ return resFailed
55+ }
56+ if code == 0 {
57+ break
58+ }
59+ if try < pullAttempts {
60+ d := j.pullBackoff()
61+ j.tagged(num, "##[warning]", fmt.Sprintf("Docker pull failed with exit code %d, back off %v seconds before retry.", code, d.Seconds()))
62+ if !j.sleepOrCancel(d, cancel) {
63+ j.out(num, "##[endgroup]")
64+ return j.cancelledStep(num)
65+ }
66+ }
67+ }
68+ j.out(num, "##[endgroup]")
69+ if code != 0 {
70+ j.errorf(num, "Docker pull failed with exit code %d", code)
71+ return resFailed
72+ }
73+ for _, n := range users {
74+ n.image = image
75+ }
76+ return resSucceeded
77+}
78+
79+// buildActionImage is ActionManager.BuildActionContainerAsync (:848-910).
80+func (j *job) buildActionImage(s *stepRun, info buildInfo, users []*node, cancel <-chan struct{}) stepResult {
81+ num := s.num
82+ j.out(num, fmt.Sprintf("##[group]Build container for action use: '%s'.", info.dockerfile))
83+ tag := newImageTag(j.dockerLabel())
84+ code := 0
85+ for try := 1; try <= buildAttempts; try++ {
86+ c := j.dockerBuildCmd(tag, info.dockerfile, dirOf(info.dockerfile))
87+ var err error
88+ code, err = j.runDockerCmd(s, c, dirOrCwd(info.workDir), nil, cancel)
89+ if err == errCancelled {
90+ j.out(num, "##[endgroup]")
91+ return j.cancelledStep(num)
92+ }
93+ if err != nil {
94+ j.out(num, "##[endgroup]")
95+ j.errorf(num, "%s", err.Error())
96+ return resFailed
97+ }
98+ if code == 0 {
99+ break
100+ }
101+ if try < buildAttempts {
102+ d := j.pullBackoff()
103+ j.tagged(num, "##[warning]", fmt.Sprintf("Docker build failed with exit code %d, back off %v seconds before retry.", code, d.Seconds()))
104+ if !j.sleepOrCancel(d, cancel) {
105+ j.out(num, "##[endgroup]")
106+ return j.cancelledStep(num)
107+ }
108+ }
109+ }
110+ j.out(num, "##[endgroup]")
111+ if code != 0 {
112+ j.errorf(num, "Docker build failed with exit code %d", code)
113+ return resFailed
114+ }
115+ for _, n := range users {
116+ n.image = tag
117+ }
118+ return resSucceeded
119+}
120+
121+func dirOf(p string) string {
122+ for i := len(p) - 1; i >= 0; i-- {
123+ if p[i] == os.PathSeparator {
124+ return p[:i]
125+ }
126+ }
127+ return "."
128+}
129+
130+// pullBackoff is a random wait of 1 to 10 seconds.
131+func (j *job) pullBackoff() time.Duration {
132+ if f := j.cfg.Actions.Backoff; f != nil {
133+ return f()
134+ }
135+ return time.Duration(1+time.Now().UnixNano()%10) * time.Second
136+}
137+
138+func (j *job) sleepOrCancel(d time.Duration, cancel <-chan struct{}) bool {
139+ select {
140+ case <-time.After(d):
141+ return true
142+ case <-cancel:
143+ return false
144+ }
145+}
@@ -0,0 +1,145 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "fmt"
7+ "os"
8+ "time"
9+)
10+
11+const (
12+ pullAttempts = 3
13+ buildAttempts = 3
14+)
15+
16+// setupSteps are the steps that pull and build the images of the actions, as
17+// JobExtensionRunner steps before the first one of the job
18+// (ActionManager.PrepareActionsAsync, ActionManager.cs:134-166): the pulls in
19+// the order the images were met, then the builds. They run if success().
20+func (j *job) setupSteps() []qstep {
21+ st := j.actions
22+ var out []qstep
23+ for _, img := range st.pullOrder {
24+ img, users := img, st.pullUsers[img]
25+ out = append(out, qstep{name: "Pull " + img, setup: func(s *stepRun, cancel <-chan struct{}) stepResult {
26+ return j.pullImage(s, img, users, cancel)
27+ }})
28+ }
29+ for _, repo := range st.buildOrder {
30+ users, info := st.buildUsers[repo], st.buildInfo[repo]
31+ out = append(out, qstep{name: "Build " + info.repository, setup: func(s *stepRun, cancel <-chan struct{}) stepResult {
32+ return j.buildActionImage(s, info, users, cancel)
33+ }})
34+ }
35+ return out
36+}
37+
38+// pullImage is ActionManager.PullActionContainerAsync (ActionManager.cs:803-846).
39+func (j *job) pullImage(s *stepRun, image string, users []*node, cancel <-chan struct{}) stepResult {
40+ num := s.num
41+ j.out(num, fmt.Sprintf("##[group]Pull down action image '%s'", image))
42+ code := 0
43+ for try := 1; try <= pullAttempts; try++ {
44+ c := j.newDockerCmd("pull", raw(image))
45+ var err error
46+ code, err = j.runDockerCmd(s, c, dirOrCwd(j.state.GitHub("workspace")), nil, cancel)
47+ if err == errCancelled {
48+ j.out(num, "##[endgroup]")
49+ return j.cancelledStep(num)
50+ }
51+ if err != nil {
52+ j.out(num, "##[endgroup]")
53+ j.errorf(num, "%s", err.Error())
54+ return resFailed
55+ }
56+ if code == 0 {
57+ break
58+ }
59+ if try < pullAttempts {
60+ d := j.pullBackoff()
61+ j.tagged(num, "##[warning]", fmt.Sprintf("Docker pull failed with exit code %d, back off %v seconds before retry.", code, d.Seconds()))
62+ if !j.sleepOrCancel(d, cancel) {
63+ j.out(num, "##[endgroup]")
64+ return j.cancelledStep(num)
65+ }
66+ }
67+ }
68+ j.out(num, "##[endgroup]")
69+ if code != 0 {
70+ j.errorf(num, "Docker pull failed with exit code %d", code)
71+ return resFailed
72+ }
73+ for _, n := range users {
74+ n.image = image
75+ }
76+ return resSucceeded
77+}
78+
79+// buildActionImage is ActionManager.BuildActionContainerAsync (:848-910).
80+func (j *job) buildActionImage(s *stepRun, info buildInfo, users []*node, cancel <-chan struct{}) stepResult {
81+ num := s.num
82+ j.out(num, fmt.Sprintf("##[group]Build container for action use: '%s'.", info.dockerfile))
83+ tag := newImageTag(j.dockerLabel())
84+ code := 0
85+ for try := 1; try <= buildAttempts; try++ {
86+ c := j.dockerBuildCmd(tag, info.dockerfile, dirOf(info.dockerfile))
87+ var err error
88+ code, err = j.runDockerCmd(s, c, dirOrCwd(info.workDir), nil, cancel)
89+ if err == errCancelled {
90+ j.out(num, "##[endgroup]")
91+ return j.cancelledStep(num)
92+ }
93+ if err != nil {
94+ j.out(num, "##[endgroup]")
95+ j.errorf(num, "%s", err.Error())
96+ return resFailed
97+ }
98+ if code == 0 {
99+ break
100+ }
101+ if try < buildAttempts {
102+ d := j.pullBackoff()
103+ j.tagged(num, "##[warning]", fmt.Sprintf("Docker build failed with exit code %d, back off %v seconds before retry.", code, d.Seconds()))
104+ if !j.sleepOrCancel(d, cancel) {
105+ j.out(num, "##[endgroup]")
106+ return j.cancelledStep(num)
107+ }
108+ }
109+ }
110+ j.out(num, "##[endgroup]")
111+ if code != 0 {
112+ j.errorf(num, "Docker build failed with exit code %d", code)
113+ return resFailed
114+ }
115+ for _, n := range users {
116+ n.image = tag
117+ }
118+ return resSucceeded
119+}
120+
121+func dirOf(p string) string {
122+ for i := len(p) - 1; i >= 0; i-- {
123+ if p[i] == os.PathSeparator {
124+ return p[:i]
125+ }
126+ }
127+ return "."
128+}
129+
130+// pullBackoff is a random wait of 1 to 10 seconds.
131+func (j *job) pullBackoff() time.Duration {
132+ if f := j.cfg.Actions.Backoff; f != nil {
133+ return f()
134+ }
135+ return time.Duration(1+time.Now().UnixNano()%10) * time.Second
136+}
137+
138+func (j *job) sleepOrCancel(d time.Duration, cancel <-chan struct{}) bool {
139+ select {
140+ case <-time.After(d):
141+ return true
142+ case <-cancel:
143+ return false
144+ }
145+}
A internal/executor/actions_state.go +20 -0
@@ -0,0 +1,20 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+// SaveState keeps a value for the later stages of the action of the step: it
6+// becomes STATE_<name> in its environment (ActionRunner.cs:225-229). The runner
7+// keeps it per action: for a step of the job in the intra-action state of the
8+// step's contexts, for a step inside a composite action at the root, by the
9+// id of the step (FileCommandManager.cs:305-318, ActionCommandManager.cs:395-
10+// 405). Here both are the state of the step's node, which its pre, main and post
11+// stages share. Names compare without case.
12+//
13+// This is the integration point with the workflow commands: the `save-state`
14+// command and the GITHUB_STATE file both end here.
15+func (s *stepRun) SaveState(name, value string) {
16+ if s.n == nil {
17+ return
18+ }
19+ s.n.setState(name, value)
20+}
@@ -0,0 +1,20 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+// SaveState keeps a value for the later stages of the action of the step: it
6+// becomes STATE_<name> in its environment (ActionRunner.cs:225-229). The runner
7+// keeps it per action: for a step of the job in the intra-action state of the
8+// step's contexts, for a step inside a composite action at the root, by the
9+// id of the step (FileCommandManager.cs:305-318, ActionCommandManager.cs:395-
10+// 405). Here both are the state of the step's node, which its pre, main and post
11+// stages share. Names compare without case.
12+//
13+// This is the integration point with the workflow commands: the `save-state`
14+// command and the GITHUB_STATE file both end here.
15+func (s *stepRun) SaveState(name, value string) {
16+ if s.n == nil {
17+ return
18+ }
19+ s.n.setState(name, value)
20+}
M internal/executor/actions_types.go +90 -47
@@ -6,6 +6,7 @@ import (
66 "context"
77 "net/http"
88 "strings"
9+ "time"
910
1011 "bnema.dev/ermite-actions/action"
1112 "bnema.dev/ermite-actions/workflow"
@@ -40,6 +41,9 @@ type ActionsConfig struct {
4041 // (HandlerFactory.cs, NodeUtil.DetermineActionsNodeVersion): ermite has no
4142 // feature flags, so the default is pinned here.
4243 Node24ByDefault bool
44+ // Backoff, when set, replaces the random 10 to 30 s wait between download
45+ // attempts (tests).
46+ Backoff func() time.Duration
4347 }
4448
4549 const defaultExternalsDir = "/home/runner/externals"
@@ -57,20 +61,27 @@ const (
5761 // (Runner.Common/Constants.cs:46).
5862 const compositeMaxDepth = 9
5963
60-// actionDef is a loaded action: its directory and manifest (ActionManager.
61-// LoadAction's Definition). A docker:// step has no manifest and no directory.
64+// kind is what an action runs.
65+type kind int
66+
67+const (
68+ kindScript kind = iota
69+ kindNode
70+ kindDocker
71+ kindComposite
72+)
73+
74+// actionDef is a loaded action (ActionManager.LoadAction's Definition).
6275 type actionDef struct {
63- uses action.Uses
64- // ref is "Name[/Path]@Ref" for repository actions, the path for local ones.
65- ref string
76+ kind kind
77+ // dir is the directory of the action ("" for docker://).
6678 dir string
67- act *action.Action // nil for docker:// and Dockerfile-only actions
68- // image is what a container action runs: "docker://x" steps and docker://
69- // manifests give the image to pull, Dockerfile ones the path of the file.
70- image string
71- dockerfile bool
72- // children are the loaded actions of a composite's steps, by step.
73- children map[*workflow.Step]*actionDef
79+ act *action.Action // nil for docker:// and for a Dockerfile without manifest
80+ // image is the "runs.image" of a container action, or the image of a
81+ // docker:// step; "" for Dockerfile-only actions (see dockerfile).
82+ image string
83+ // dockerfile is the path of the Dockerfile of a Dockerfile-only action.
84+ dockerfile string
7485 }
7586
7687 func (d *actionDef) runs() *action.Runs {
@@ -80,42 +91,8 @@ func (d *actionDef) runs() *action.Runs {
8091 return d.act.Runs
8192 }
8293
83-// hasPre and hasPost are ActionExecutionData.HasPre/HasPost.
84-func (d *actionDef) hasPre() bool {
85- r := d.runs()
86- switch {
87- case r == nil:
88- return false
89- case r.Kind == action.RunsComposite:
90- return d.anyChild(func(c *actionDef) bool { return c.hasPre() && !c.local() })
91- }
92- return r.HasPre()
93-}
94-
95-func (d *actionDef) hasPost() bool {
96- r := d.runs()
97- switch {
98- case r == nil:
99- return false
100- case r.Kind == action.RunsComposite:
101- return d.anyChild(func(c *actionDef) bool { return c.local() || c.hasPost() })
102- }
103- return r.HasPost()
104-}
105-
106-func (d *actionDef) anyChild(f func(*actionDef) bool) bool {
107- for _, c := range d.children {
108- if c != nil && f(c) {
109- return true
110- }
111- }
112- return false
113-}
114-
115-func (d *actionDef) local() bool { return d.uses.Kind == action.UsesLocal }
116-
11794 // preIf and postIf are InitCondition and CleanupCondition: always() unless the
118-// manifest says otherwise (action.Runs keeps them as written).
95+// manifest says otherwise (ActionExecutionData, ActionManager.cs:1884-1900).
11996 func (d *actionDef) preIf() string { return condOrAlways(d.runs(), true) }
12097 func (d *actionDef) postIf() string { return condOrAlways(d.runs(), false) }
12198
@@ -132,3 +109,69 @@ func condOrAlways(r *action.Runs, pre bool) string {
132109 }
133110 return c
134111 }
112+
113+// node is one step of the job or of a composite action, with what the runner
114+// keeps per action step: its intra-action state, and for a composite its
115+// children and its pre and post steps (CompositeActionExecutionData).
116+type node struct {
117+ st *workflow.Step
118+ uses action.Uses
119+ parent *node
120+ // scope is the name of the steps context of the step's children: the
121+ // fully qualified context name (ExecutionContext.GetFullyQualifiedContextName).
122+ scope string
123+ state *ordered
124+ // children are the nodes of a composite action's steps.
125+ children []*node
126+ // preSteps and postSteps are the embedded steps with a pre or a post, in
127+ // declaration order (the runner's post steps are a stack: they run last
128+ // to first).
129+ preSteps, postSteps []*node
130+ // image is the image the setup steps pulled or built for the step.
131+ image string
132+ def *actionDef
133+ // postRegistered is Root.StepsWithPostRegistered / EmbeddedStepsWithPostRegistered.
134+ postRegistered bool
135+ postCond string
136+ // preScope is the random steps context of the pre stage of a composite.
137+ preScope string
138+}
139+
140+func (n *node) isRun() bool { return n.st.Run != nil }
141+
142+// setState keeps a value of the intra-action state: keys compare without case
143+// (the runner's dictionary is OrdinalIgnoreCase) and keep the first spelling.
144+func (n *node) setState(k, v string) {
145+ if n.state == nil {
146+ n.state = newOrdered()
147+ }
148+ for _, have := range n.state.Keys() {
149+ if strings.EqualFold(have, k) {
150+ k = have
151+ break
152+ }
153+ }
154+ n.state.Set(k, v)
155+}
156+
157+// newNodes makes the nodes of steps. The workflow reader has checked every
158+// uses value.
159+func newNodes(steps []*workflow.Step, parent *node, scope string) ([]*node, error) {
160+ out := make([]*node, 0, len(steps))
161+ for _, st := range steps {
162+ n := &node{st: st, parent: parent}
163+ n.scope = st.ID
164+ if scope != "" {
165+ n.scope = scope + "." + st.ID
166+ }
167+ if st.Uses != nil {
168+ u, err := action.ParseUses(st.Uses.Value)
169+ if err != nil {
170+ return nil, err
171+ }
172+ n.uses = u
173+ }
174+ out = append(out, n)
175+ }
176+ return out, nil
177+}
@@ -6,6 +6,7 @@ import (
6 "context"6 "context"
7 "net/http"7 "net/http"
8 "strings"8 "strings"
9+ "time"
9 10
10 "bnema.dev/ermite-actions/action"11 "bnema.dev/ermite-actions/action"
11 "bnema.dev/ermite-actions/workflow"12 "bnema.dev/ermite-actions/workflow"
@@ -40,6 +41,9 @@ type ActionsConfig struct {
40 // (HandlerFactory.cs, NodeUtil.DetermineActionsNodeVersion): ermite has no41 // (HandlerFactory.cs, NodeUtil.DetermineActionsNodeVersion): ermite has no
41 // feature flags, so the default is pinned here.42 // feature flags, so the default is pinned here.
42 Node24ByDefault bool43 Node24ByDefault bool
44+ // Backoff, when set, replaces the random 10 to 30 s wait between download
45+ // attempts (tests).
46+ Backoff func() time.Duration
43 }47 }
44 48
45 const defaultExternalsDir = "/home/runner/externals"49 const defaultExternalsDir = "/home/runner/externals"
@@ -57,20 +61,27 @@ const (
57 // (Runner.Common/Constants.cs:46).61 // (Runner.Common/Constants.cs:46).
58 const compositeMaxDepth = 962 const compositeMaxDepth = 9
59 63
60-// actionDef is a loaded action: its directory and manifest (ActionManager.64+// kind is what an action runs.
61-// LoadAction's Definition). A docker:// step has no manifest and no directory.65+type kind int
66+
67+const (
68+ kindScript kind = iota
69+ kindNode
70+ kindDocker
71+ kindComposite
72+)
73+
74+// actionDef is a loaded action (ActionManager.LoadAction's Definition).
62 type actionDef struct {75 type actionDef struct {
63- uses action.Uses76+ kind kind
64- // ref is "Name[/Path]@Ref" for repository actions, the path for local ones.77+ // dir is the directory of the action ("" for docker://).
65- ref string
66 dir string78 dir string
67- act *action.Action // nil for docker:// and Dockerfile-only actions79+ act *action.Action // nil for docker:// and for a Dockerfile without manifest
68- // image is what a container action runs: "docker://x" steps and docker://80+ // image is the "runs.image" of a container action, or the image of a
69- // manifests give the image to pull, Dockerfile ones the path of the file.81+ // docker:// step; "" for Dockerfile-only actions (see dockerfile).
70- image string82+ image string
71- dockerfile bool83+ // dockerfile is the path of the Dockerfile of a Dockerfile-only action.
72- // children are the loaded actions of a composite's steps, by step.84+ dockerfile string
73- children map[*workflow.Step]*actionDef
74 }85 }
75 86
76 func (d *actionDef) runs() *action.Runs {87 func (d *actionDef) runs() *action.Runs {
@@ -80,42 +91,8 @@ func (d *actionDef) runs() *action.Runs {
80 return d.act.Runs91 return d.act.Runs
81 }92 }
82 93
83-// hasPre and hasPost are ActionExecutionData.HasPre/HasPost.
84-func (d *actionDef) hasPre() bool {
85- r := d.runs()
86- switch {
87- case r == nil:
88- return false
89- case r.Kind == action.RunsComposite:
90- return d.anyChild(func(c *actionDef) bool { return c.hasPre() && !c.local() })
91- }
92- return r.HasPre()
93-}
94-
95-func (d *actionDef) hasPost() bool {
96- r := d.runs()
97- switch {
98- case r == nil:
99- return false
100- case r.Kind == action.RunsComposite:
101- return d.anyChild(func(c *actionDef) bool { return c.local() || c.hasPost() })
102- }
103- return r.HasPost()
104-}
105-
106-func (d *actionDef) anyChild(f func(*actionDef) bool) bool {
107- for _, c := range d.children {
108- if c != nil && f(c) {
109- return true
110- }
111- }
112- return false
113-}
114-
115-func (d *actionDef) local() bool { return d.uses.Kind == action.UsesLocal }
116-
117 // preIf and postIf are InitCondition and CleanupCondition: always() unless the94 // preIf and postIf are InitCondition and CleanupCondition: always() unless the
118-// manifest says otherwise (action.Runs keeps them as written).95+// manifest says otherwise (ActionExecutionData, ActionManager.cs:1884-1900).
119 func (d *actionDef) preIf() string { return condOrAlways(d.runs(), true) }96 func (d *actionDef) preIf() string { return condOrAlways(d.runs(), true) }
120 func (d *actionDef) postIf() string { return condOrAlways(d.runs(), false) }97 func (d *actionDef) postIf() string { return condOrAlways(d.runs(), false) }
121 98
@@ -132,3 +109,69 @@ func condOrAlways(r *action.Runs, pre bool) string {
132 }109 }
133 return c110 return c
134 }111 }
112+
113+// node is one step of the job or of a composite action, with what the runner
114+// keeps per action step: its intra-action state, and for a composite its
115+// children and its pre and post steps (CompositeActionExecutionData).
116+type node struct {
117+ st *workflow.Step
118+ uses action.Uses
119+ parent *node
120+ // scope is the name of the steps context of the step's children: the
121+ // fully qualified context name (ExecutionContext.GetFullyQualifiedContextName).
122+ scope string
123+ state *ordered
124+ // children are the nodes of a composite action's steps.
125+ children []*node
126+ // preSteps and postSteps are the embedded steps with a pre or a post, in
127+ // declaration order (the runner's post steps are a stack: they run last
128+ // to first).
129+ preSteps, postSteps []*node
130+ // image is the image the setup steps pulled or built for the step.
131+ image string
132+ def *actionDef
133+ // postRegistered is Root.StepsWithPostRegistered / EmbeddedStepsWithPostRegistered.
134+ postRegistered bool
135+ postCond string
136+ // preScope is the random steps context of the pre stage of a composite.
137+ preScope string
138+}
139+
140+func (n *node) isRun() bool { return n.st.Run != nil }
141+
142+// setState keeps a value of the intra-action state: keys compare without case
143+// (the runner's dictionary is OrdinalIgnoreCase) and keep the first spelling.
144+func (n *node) setState(k, v string) {
145+ if n.state == nil {
146+ n.state = newOrdered()
147+ }
148+ for _, have := range n.state.Keys() {
149+ if strings.EqualFold(have, k) {
150+ k = have
151+ break
152+ }
153+ }
154+ n.state.Set(k, v)
155+}
156+
157+// newNodes makes the nodes of steps. The workflow reader has checked every
158+// uses value.
159+func newNodes(steps []*workflow.Step, parent *node, scope string) ([]*node, error) {
160+ out := make([]*node, 0, len(steps))
161+ for _, st := range steps {
162+ n := &node{st: st, parent: parent}
163+ n.scope = st.ID
164+ if scope != "" {
165+ n.scope = scope + "." + st.ID
166+ }
167+ if st.Uses != nil {
168+ u, err := action.ParseUses(st.Uses.Value)
169+ if err != nil {
170+ return nil, err
171+ }
172+ n.uses = u
173+ }
174+ out = append(out, n)
175+ }
176+ return out, nil
177+}
M internal/executor/contexts.go +25 -12
@@ -33,8 +33,15 @@ func (o *ordered) Set(k, v string) {
3333 }
3434
3535 func (o *ordered) Get(k string) (string, bool) { v, ok := o.vals[k]; return v, ok }
36-func (o *ordered) Len() int { return len(o.keys) }
37-func (o *ordered) Keys() []string { return slices.Clone(o.keys) }
36+func (o *ordered) Remove(k string) {
37+ if _, ok := o.vals[k]; !ok {
38+ return
39+ }
40+ delete(o.vals, k)
41+ o.keys = slices.DeleteFunc(o.keys, func(x string) bool { return x == k })
42+}
43+func (o *ordered) Len() int { return len(o.keys) }
44+func (o *ordered) Keys() []string { return slices.Clone(o.keys) }
3845
3946 func (o *ordered) Clone() *ordered {
4047 c := newOrdered()
@@ -213,9 +220,10 @@ type State struct {
213220 // env is Global.EnvironmentVariables: the job-level environment and what
214221 // GITHUB_ENV adds. A step's own env is not in it.
215222 env *ordered
216- // steps is the steps context, in order of first mention.
217- stepOrder []string
218- steps map[string]*stepCtx
223+ // root is the steps context of the job; cur is the one in use: a composite
224+ // action's steps see their own (see PushScope).
225+ root, cur *stepsScope
226+ scopes map[string]*stepsScope
219227 // prepend is Global.PrependPath in the order of the GITHUB_PATH entries.
220228 prepend []string
221229 // status is job.status: "success", "failure" or "cancelled".
@@ -242,7 +250,9 @@ func NewState(contextsJSON, matrixJSON, jobKey string, r RunnerInfo, repoFallbac
242250 }
243251 root = d
244252 }
245- s := &State{other: map[string]expr.Value{}, env: newOrdered(), steps: map[string]*stepCtx{}, status: "success"}
253+ s := &State{other: map[string]expr.Value{}, env: newOrdered(), status: "success", scopes: map[string]*stepsScope{}}
254+ s.root = newScope()
255+ s.cur = s.root
246256
247257 // github: token first, then what the server gave (ExecutionContext.cs:1023-1037).
248258 // The token is the one the server sent in github.token (GitHub's
@@ -407,11 +417,11 @@ func (s *State) Status() string { return s.status }
407417 func stepIDVisible(id string) bool { return id != "" && !strings.HasPrefix(id, "__") }
408418
409419 func (s *State) step(id string) *stepCtx {
410- c, ok := s.steps[id]
420+ c, ok := s.cur.steps[id]
411421 if !ok {
412422 c = &stepCtx{outputs: newOrdered()}
413- s.steps[id] = c
414- s.stepOrder = append(s.stepOrder, id)
423+ s.cur.steps[id] = c
424+ s.cur.order = append(s.cur.order, id)
415425 }
416426 return c
417427 }
@@ -437,7 +447,7 @@ func (s *State) SetStepResult(id, outcome, conclusion string) {
437447
438448 // Output returns an output of a step ("" when absent).
439449 func (s *State) Output(id, name string) string {
440- if c, ok := s.steps[id]; ok {
450+ if c, ok := s.cur.steps[id]; ok {
441451 v, _ := c.outputs.Get(name)
442452 return v
443453 }
@@ -446,8 +456,8 @@ func (s *State) Output(id, name string) string {
446456
447457 func (s *State) stepsValue() *expr.Dictionary {
448458 d := expr.NewDictionary()
449- for _, id := range s.stepOrder {
450- c := s.steps[id]
459+ for _, id := range s.cur.order {
460+ c := s.cur.steps[id]
451461 sd := expr.NewDictionary()
452462 sd.Add("outputs", c.outputs.Dict())
453463 if c.outcome != "" {
@@ -477,6 +487,9 @@ func (s *State) Values(env *ordered) map[string]expr.Value {
477487 for k, v := range s.other {
478488 m[k] = v
479489 }
490+ if s.cur.inputs != nil {
491+ m["inputs"] = s.cur.inputs
492+ }
480493 return m
481494 }
482495
@@ -33,8 +33,15 @@ func (o *ordered) Set(k, v string) {
33 }33 }
34 34
35 func (o *ordered) Get(k string) (string, bool) { v, ok := o.vals[k]; return v, ok }35 func (o *ordered) Get(k string) (string, bool) { v, ok := o.vals[k]; return v, ok }
36-func (o *ordered) Len() int { return len(o.keys) }36+func (o *ordered) Remove(k string) {
37-func (o *ordered) Keys() []string { return slices.Clone(o.keys) }37+ if _, ok := o.vals[k]; !ok {
38+ return
39+ }
40+ delete(o.vals, k)
41+ o.keys = slices.DeleteFunc(o.keys, func(x string) bool { return x == k })
42+}
43+func (o *ordered) Len() int { return len(o.keys) }
44+func (o *ordered) Keys() []string { return slices.Clone(o.keys) }
38 45
39 func (o *ordered) Clone() *ordered {46 func (o *ordered) Clone() *ordered {
40 c := newOrdered()47 c := newOrdered()
@@ -213,9 +220,10 @@ type State struct {
213 // env is Global.EnvironmentVariables: the job-level environment and what220 // env is Global.EnvironmentVariables: the job-level environment and what
214 // GITHUB_ENV adds. A step's own env is not in it.221 // GITHUB_ENV adds. A step's own env is not in it.
215 env *ordered222 env *ordered
216- // steps is the steps context, in order of first mention.223+ // root is the steps context of the job; cur is the one in use: a composite
217- stepOrder []string224+ // action's steps see their own (see PushScope).
218- steps map[string]*stepCtx225+ root, cur *stepsScope
226+ scopes map[string]*stepsScope
219 // prepend is Global.PrependPath in the order of the GITHUB_PATH entries.227 // prepend is Global.PrependPath in the order of the GITHUB_PATH entries.
220 prepend []string228 prepend []string
221 // status is job.status: "success", "failure" or "cancelled".229 // status is job.status: "success", "failure" or "cancelled".
@@ -242,7 +250,9 @@ func NewState(contextsJSON, matrixJSON, jobKey string, r RunnerInfo, repoFallbac
242 }250 }
243 root = d251 root = d
244 }252 }
245- s := &State{other: map[string]expr.Value{}, env: newOrdered(), steps: map[string]*stepCtx{}, status: "success"}253+ s := &State{other: map[string]expr.Value{}, env: newOrdered(), status: "success", scopes: map[string]*stepsScope{}}
254+ s.root = newScope()
255+ s.cur = s.root
246 256
247 // github: token first, then what the server gave (ExecutionContext.cs:1023-1037).257 // github: token first, then what the server gave (ExecutionContext.cs:1023-1037).
248 // The token is the one the server sent in github.token (GitHub's258 // The token is the one the server sent in github.token (GitHub's
@@ -407,11 +417,11 @@ func (s *State) Status() string { return s.status }
407 func stepIDVisible(id string) bool { return id != "" && !strings.HasPrefix(id, "__") }417 func stepIDVisible(id string) bool { return id != "" && !strings.HasPrefix(id, "__") }
408 418
409 func (s *State) step(id string) *stepCtx {419 func (s *State) step(id string) *stepCtx {
410- c, ok := s.steps[id]420+ c, ok := s.cur.steps[id]
411 if !ok {421 if !ok {
412 c = &stepCtx{outputs: newOrdered()}422 c = &stepCtx{outputs: newOrdered()}
413- s.steps[id] = c423+ s.cur.steps[id] = c
414- s.stepOrder = append(s.stepOrder, id)424+ s.cur.order = append(s.cur.order, id)
415 }425 }
416 return c426 return c
417 }427 }
@@ -437,7 +447,7 @@ func (s *State) SetStepResult(id, outcome, conclusion string) {
437 447
438 // Output returns an output of a step ("" when absent).448 // Output returns an output of a step ("" when absent).
439 func (s *State) Output(id, name string) string {449 func (s *State) Output(id, name string) string {
440- if c, ok := s.steps[id]; ok {450+ if c, ok := s.cur.steps[id]; ok {
441 v, _ := c.outputs.Get(name)451 v, _ := c.outputs.Get(name)
442 return v452 return v
443 }453 }
@@ -446,8 +456,8 @@ func (s *State) Output(id, name string) string {
446 456
447 func (s *State) stepsValue() *expr.Dictionary {457 func (s *State) stepsValue() *expr.Dictionary {
448 d := expr.NewDictionary()458 d := expr.NewDictionary()
449- for _, id := range s.stepOrder {459+ for _, id := range s.cur.order {
450- c := s.steps[id]460+ c := s.cur.steps[id]
451 sd := expr.NewDictionary()461 sd := expr.NewDictionary()
452 sd.Add("outputs", c.outputs.Dict())462 sd.Add("outputs", c.outputs.Dict())
453 if c.outcome != "" {463 if c.outcome != "" {
@@ -477,6 +487,9 @@ func (s *State) Values(env *ordered) map[string]expr.Value {
477 for k, v := range s.other {487 for k, v := range s.other {
478 m[k] = v488 m[k] = v
479 }489 }
490+ if s.cur.inputs != nil {
491+ m["inputs"] = s.cur.inputs
492+ }
480 return m493 return m
481 }494 }
482 495
M internal/executor/exec_step.go +37 -19
@@ -7,19 +7,13 @@ import (
77 "path/filepath"
88 "strings"
99
10- "bnema.dev/ermite-actions/workflow"
1110 )
1211
1312 // execute is ActionRunner.RunAsync plus ScriptHandler for one run step.
1413 func (j *job) execute(s *stepRun, stepCancel <-chan struct{}) stepResult {
1514 num, st := s.num, s.st
16- if msg := notSupported(st); msg != "" {
17- if st.Uses != nil {
18- j.out(num, "##[group]Run "+st.Uses.Value)
19- j.out(num, "##[endgroup]")
20- }
21- j.errorf(num, "%s", msg)
22- return resFailed
15+ if st.Uses != nil {
16+ return j.runAction(s, stepCancel)
2317 }
2418 fail := func(err error) stepResult { j.errorf(num, "%s", err.Error()); return resFailed }
2519 failTemplate := func(err error) stepResult {
@@ -27,11 +21,11 @@ func (j *job) execute(s *stepRun, stepCancel <-chan struct{}) stepResult {
2721 return resFailed
2822 }
2923
30- if err := j.prepareFileCommands(st); err != nil {
24+ if err := j.prepareFileCommands(s); err != nil {
3125 return fail(err)
3226 }
3327 // ProcessFiles runs after the handler, even when it failed (a finally block).
34- defer j.fc.Process(s)
28+ defer s.fc.Process(s)
3529
3630 in, err := j.scriptInputs(s)
3731 if err != nil {
@@ -67,7 +61,10 @@ func (j *job) scriptInputs(s *stepRun) (scriptIn, error) {
6761 if in.shell, err = j.evalString(st.Shell, s.env); err != nil {
6862 return in, err
6963 }
70- if in.shell == "" {
64+ // defaults.run applies to the steps of the job only, not to the ones of a
65+ // composite action (ScriptHandler.cs:158-181: ScopeName is empty).
66+ top := s.parent == nil
67+ if in.shell == "" && top {
7168 in.shell = j.runDefaults.shell
7269 }
7370 if st.WorkingDirectory != nil {
@@ -76,7 +73,7 @@ func (j *job) scriptInputs(s *stepRun) (scriptIn, error) {
7673 return in, err
7774 }
7875 }
79- if !in.hasWorkingDirectory {
76+ if !in.hasWorkingDirectory && top {
8077 in.workingDirectory = j.runDefaults.workingDirectory
8178 }
8279 return in, nil
@@ -85,18 +82,25 @@ func (j *job) scriptInputs(s *stepRun) (scriptIn, error) {
8582 // prepareFileCommands creates the files of the step, puts their paths in the
8683 // github context (FileCommandManager.InitializeFiles) and writes event.json
8784 // (ExecutionContext.WriteWebhookPayload).
88-func (j *job) prepareFileCommands(st *workflow.Step) error {
85+func (j *job) prepareFileCommands(s *stepRun) error {
8986 j.mu.Lock()
9087 defer j.mu.Unlock()
91- files, err := j.fc.Init()
88+ if s.fc == nil {
89+ s.fc = j.fc
90+ }
91+ files, err := s.fc.Init()
9292 if err != nil {
9393 return err
9494 }
9595 for _, c := range fileCommands {
9696 j.state.SetGitHub(c.ContextName, files[envVarName(c.ContextName)])
9797 }
98- j.state.SetGitHub("action_repository", "")
99- j.state.SetGitHub("action_ref", "")
98+ if s.n == nil || s.n.st.Uses == nil {
99+ // ActionRunner.cs:149-165 sets them for every action step, a run step
100+ // included (its reference is a script, so they are empty).
101+ j.state.SetGitHub("action_repository", "")
102+ j.state.SetGitHub("action_ref", "")
103+ }
100104 if ev := j.state.EventJSON(); ev != "" {
101105 if p, err := j.writeEvent(ev); err == nil {
102106 j.state.SetGitHub("event_path", p)
@@ -191,9 +195,18 @@ func (j *job) processEnv(stepEnv *ordered) []string {
191195 // startProcess runs the script and turns how it ended into the result of the
192196 // step.
193197 func (j *job) startProcess(s *stepRun, plan ShellPlan, dir string, argv []string, stepCancel <-chan struct{}) stepResult {
198+ return j.runProc(s, plan.CommandPath, dir, argv, "", stepCancel)
199+}
200+
201+// runProc starts a process (exitDebug, when set, is the format of the debug line that
202+// replaces the error about the exit code) for the step and streams its output through the
203+// commands of the step. A script reports its exit code (ScriptHandler.cs:
204+// 267-275); the handlers of actions only mark the step failed (NodeScriptAction
205+// Handler.cs:178-184), a failing action says why itself.
206+func (j *job) runProc(s *stepRun, shown, dir string, argv []string, exitDebug string, stepCancel <-chan struct{}) stepResult {
194207 num := s.num
195208 res, err := runProcess(procSpec{
196- Argv: argv, Dir: dir, Env: j.processEnv(s.env), Credential: j.cfg.Credential,
209+ Argv: argv, Dir: dir, Env: s.procEnv(), Credential: j.cfg.Credential,
197210 OnLine: func(line string) {
198211 if strings.Contains(line, "##[start-action") || strings.Contains(line, "##[end-action") {
199212 line = strings.ReplaceAll(strings.ReplaceAll(line, "##[start-action", `##[\start-action`), "##[end-action", `##[\end-action`)
@@ -207,7 +220,7 @@ func (j *job) startProcess(s *stepRun, plan ShellPlan, dir string, argv []string
207220 WaitDelay: j.cfg.WaitDelay, Signal: j.cfg.Signal,
208221 })
209222 if err != nil {
210- j.errorf(num, "%s", startError(plan.CommandPath, dir, err))
223+ j.errorf(num, "%s", startError(shown, dir, err))
211224 return resFailed
212225 }
213226 if res.Cancelled {
@@ -217,8 +230,13 @@ func (j *job) startProcess(s *stepRun, plan ShellPlan, dir string, argv []string
217230 j.errorf(num, "The operation was canceled.")
218231 return resCanceled
219232 }
233+ if exitDebug != "" {
234+ j.debug(num, fmt.Sprintf(exitDebug, res.ExitCode))
235+ }
220236 if res.ExitCode != 0 {
221- j.errorf(num, "Process completed with exit code %d.", res.ExitCode)
237+ if exitDebug == "" {
238+ j.errorf(num, "Process completed with exit code %d.", res.ExitCode)
239+ }
222240 return resFailed
223241 }
224242 return resSucceeded
@@ -7,19 +7,13 @@ import (
7 "path/filepath"7 "path/filepath"
8 "strings"8 "strings"
9 9
10- "bnema.dev/ermite-actions/workflow"
11 )10 )
12 11
13 // execute is ActionRunner.RunAsync plus ScriptHandler for one run step.12 // execute is ActionRunner.RunAsync plus ScriptHandler for one run step.
14 func (j *job) execute(s *stepRun, stepCancel <-chan struct{}) stepResult {13 func (j *job) execute(s *stepRun, stepCancel <-chan struct{}) stepResult {
15 num, st := s.num, s.st14 num, st := s.num, s.st
16- if msg := notSupported(st); msg != "" {15+ if st.Uses != nil {
17- if st.Uses != nil {16+ return j.runAction(s, stepCancel)
18- j.out(num, "##[group]Run "+st.Uses.Value)
19- j.out(num, "##[endgroup]")
20- }
21- j.errorf(num, "%s", msg)
22- return resFailed
23 }17 }
24 fail := func(err error) stepResult { j.errorf(num, "%s", err.Error()); return resFailed }18 fail := func(err error) stepResult { j.errorf(num, "%s", err.Error()); return resFailed }
25 failTemplate := func(err error) stepResult {19 failTemplate := func(err error) stepResult {
@@ -27,11 +21,11 @@ func (j *job) execute(s *stepRun, stepCancel <-chan struct{}) stepResult {
27 return resFailed21 return resFailed
28 }22 }
29 23
30- if err := j.prepareFileCommands(st); err != nil {24+ if err := j.prepareFileCommands(s); err != nil {
31 return fail(err)25 return fail(err)
32 }26 }
33 // ProcessFiles runs after the handler, even when it failed (a finally block).27 // ProcessFiles runs after the handler, even when it failed (a finally block).
34- defer j.fc.Process(s)28+ defer s.fc.Process(s)
35 29
36 in, err := j.scriptInputs(s)30 in, err := j.scriptInputs(s)
37 if err != nil {31 if err != nil {
@@ -67,7 +61,10 @@ func (j *job) scriptInputs(s *stepRun) (scriptIn, error) {
67 if in.shell, err = j.evalString(st.Shell, s.env); err != nil {61 if in.shell, err = j.evalString(st.Shell, s.env); err != nil {
68 return in, err62 return in, err
69 }63 }
70- if in.shell == "" {64+ // defaults.run applies to the steps of the job only, not to the ones of a
65+ // composite action (ScriptHandler.cs:158-181: ScopeName is empty).
66+ top := s.parent == nil
67+ if in.shell == "" && top {
71 in.shell = j.runDefaults.shell68 in.shell = j.runDefaults.shell
72 }69 }
73 if st.WorkingDirectory != nil {70 if st.WorkingDirectory != nil {
@@ -76,7 +73,7 @@ func (j *job) scriptInputs(s *stepRun) (scriptIn, error) {
76 return in, err73 return in, err
77 }74 }
78 }75 }
79- if !in.hasWorkingDirectory {76+ if !in.hasWorkingDirectory && top {
80 in.workingDirectory = j.runDefaults.workingDirectory77 in.workingDirectory = j.runDefaults.workingDirectory
81 }78 }
82 return in, nil79 return in, nil
@@ -85,18 +82,25 @@ func (j *job) scriptInputs(s *stepRun) (scriptIn, error) {
85 // prepareFileCommands creates the files of the step, puts their paths in the82 // prepareFileCommands creates the files of the step, puts their paths in the
86 // github context (FileCommandManager.InitializeFiles) and writes event.json83 // github context (FileCommandManager.InitializeFiles) and writes event.json
87 // (ExecutionContext.WriteWebhookPayload).84 // (ExecutionContext.WriteWebhookPayload).
88-func (j *job) prepareFileCommands(st *workflow.Step) error {85+func (j *job) prepareFileCommands(s *stepRun) error {
89 j.mu.Lock()86 j.mu.Lock()
90 defer j.mu.Unlock()87 defer j.mu.Unlock()
91- files, err := j.fc.Init()88+ if s.fc == nil {
89+ s.fc = j.fc
90+ }
91+ files, err := s.fc.Init()
92 if err != nil {92 if err != nil {
93 return err93 return err
94 }94 }
95 for _, c := range fileCommands {95 for _, c := range fileCommands {
96 j.state.SetGitHub(c.ContextName, files[envVarName(c.ContextName)])96 j.state.SetGitHub(c.ContextName, files[envVarName(c.ContextName)])
97 }97 }
98- j.state.SetGitHub("action_repository", "")98+ if s.n == nil || s.n.st.Uses == nil {
99- j.state.SetGitHub("action_ref", "")99+ // ActionRunner.cs:149-165 sets them for every action step, a run step
100+ // included (its reference is a script, so they are empty).
101+ j.state.SetGitHub("action_repository", "")
102+ j.state.SetGitHub("action_ref", "")
103+ }
100 if ev := j.state.EventJSON(); ev != "" {104 if ev := j.state.EventJSON(); ev != "" {
101 if p, err := j.writeEvent(ev); err == nil {105 if p, err := j.writeEvent(ev); err == nil {
102 j.state.SetGitHub("event_path", p)106 j.state.SetGitHub("event_path", p)
@@ -191,9 +195,18 @@ func (j *job) processEnv(stepEnv *ordered) []string {
191 // startProcess runs the script and turns how it ended into the result of the195 // startProcess runs the script and turns how it ended into the result of the
192 // step.196 // step.
193 func (j *job) startProcess(s *stepRun, plan ShellPlan, dir string, argv []string, stepCancel <-chan struct{}) stepResult {197 func (j *job) startProcess(s *stepRun, plan ShellPlan, dir string, argv []string, stepCancel <-chan struct{}) stepResult {
198+ return j.runProc(s, plan.CommandPath, dir, argv, "", stepCancel)
199+}
200+
201+// runProc starts a process (exitDebug, when set, is the format of the debug line that
202+// replaces the error about the exit code) for the step and streams its output through the
203+// commands of the step. A script reports its exit code (ScriptHandler.cs:
204+// 267-275); the handlers of actions only mark the step failed (NodeScriptAction
205+// Handler.cs:178-184), a failing action says why itself.
206+func (j *job) runProc(s *stepRun, shown, dir string, argv []string, exitDebug string, stepCancel <-chan struct{}) stepResult {
194 num := s.num207 num := s.num
195 res, err := runProcess(procSpec{208 res, err := runProcess(procSpec{
196- Argv: argv, Dir: dir, Env: j.processEnv(s.env), Credential: j.cfg.Credential,209+ Argv: argv, Dir: dir, Env: s.procEnv(), Credential: j.cfg.Credential,
197 OnLine: func(line string) {210 OnLine: func(line string) {
198 if strings.Contains(line, "##[start-action") || strings.Contains(line, "##[end-action") {211 if strings.Contains(line, "##[start-action") || strings.Contains(line, "##[end-action") {
199 line = strings.ReplaceAll(strings.ReplaceAll(line, "##[start-action", `##[\start-action`), "##[end-action", `##[\end-action`)212 line = strings.ReplaceAll(strings.ReplaceAll(line, "##[start-action", `##[\start-action`), "##[end-action", `##[\end-action`)
@@ -207,7 +220,7 @@ func (j *job) startProcess(s *stepRun, plan ShellPlan, dir string, argv []string
207 WaitDelay: j.cfg.WaitDelay, Signal: j.cfg.Signal,220 WaitDelay: j.cfg.WaitDelay, Signal: j.cfg.Signal,
208 })221 })
209 if err != nil {222 if err != nil {
210- j.errorf(num, "%s", startError(plan.CommandPath, dir, err))223+ j.errorf(num, "%s", startError(shown, dir, err))
211 return resFailed224 return resFailed
212 }225 }
213 if res.Cancelled {226 if res.Cancelled {
@@ -217,8 +230,13 @@ func (j *job) startProcess(s *stepRun, plan ShellPlan, dir string, argv []string
217 j.errorf(num, "The operation was canceled.")230 j.errorf(num, "The operation was canceled.")
218 return resCanceled231 return resCanceled
219 }232 }
233+ if exitDebug != "" {
234+ j.debug(num, fmt.Sprintf(exitDebug, res.ExitCode))
235+ }
220 if res.ExitCode != 0 {236 if res.ExitCode != 0 {
221- j.errorf(num, "Process completed with exit code %d.", res.ExitCode)237+ if exitDebug == "" {
238+ j.errorf(num, "Process completed with exit code %d.", res.ExitCode)
239+ }
222 return resFailed240 return resFailed
223 }241 }
224 return resSucceeded242 return resSucceeded
A internal/executor/filecmd_fork.go +22 -0
@@ -0,0 +1,22 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+// Fork returns file commands that share the directory and the root of f with
6+// a suffix of their own: every action step has its own manager in the runner
7+// (HostContext.CreateService<IFileCommandManager>(), ActionRunner.cs:173), so
8+// the steps of a composite action do not delete the files of the step around
9+// them. The fork must not be closed.
10+func (f *FileCommands) Fork() *FileCommands {
11+ return &FileCommands{dir: f.dir, root: f.root, owner: f.owner}
12+}
13+
14+// Cleanup removes the files of the current step of a fork.
15+func (f *FileCommands) Cleanup() {
16+ if f.suffix == "" {
17+ return
18+ }
19+ for _, c := range fileCommands {
20+ _ = f.root.Remove(f.name(c, f.suffix))
21+ }
22+}
@@ -0,0 +1,22 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+// Fork returns file commands that share the directory and the root of f with
6+// a suffix of their own: every action step has its own manager in the runner
7+// (HostContext.CreateService<IFileCommandManager>(), ActionRunner.cs:173), so
8+// the steps of a composite action do not delete the files of the step around
9+// them. The fork must not be closed.
10+func (f *FileCommands) Fork() *FileCommands {
11+ return &FileCommands{dir: f.dir, root: f.root, owner: f.owner}
12+}
13+
14+// Cleanup removes the files of the current step of a fork.
15+func (f *FileCommands) Cleanup() {
16+ if f.suffix == "" {
17+ return
18+ }
19+ for _, c := range fileCommands {
20+ _ = f.root.Remove(f.name(c, f.suffix))
21+ }
22+}
M internal/executor/job.go +45 -6
@@ -151,6 +151,15 @@ type job struct {
151151 // masker hides the secrets in everything reported; it is set once the
152152 // contexts are built, before any step runs.
153153 masker atomic.Pointer[masker]
154+ // extraSecrets are registered after the start (see addSecrets).
155+ extraSecrets []string
156+ // actions holds what the pre-job phase loaded (see prepareActions).
157+ actions *actionState
158+ // postStack is the post steps registered so far, last registered last.
159+ postStack []qstep
160+ // upgradedToNode24 are the actions that asked for node20 and got node24.
161+ upgradedToNode24 []string
162+ jobCtx context.Context
154163 }
155164
156165 func (j *job) mask(s string) string { return j.masker.Load().mask(s) }
@@ -195,6 +204,16 @@ func (j *job) run() Result {
195204 // ---- Set up job (JobExtension.InitializeJob) ----
196205 rep := j.cfg.Reporter
197206 rep.StepStarted(1, SetUpJobName, j.now())
207+ var cancelCtx context.CancelFunc
208+ j.jobCtx, cancelCtx = context.WithCancel(context.Background())
209+ defer cancelCtx()
210+ go func() {
211+ select {
212+ case <-j.cancel.Done():
213+ cancelCtx()
214+ case <-j.jobCtx.Done():
215+ }
216+ }()
198217 steps, err := j.setUp()
199218 if err != nil {
200219 j.errorf(1, "%s", err.Error())
@@ -205,13 +224,29 @@ func (j *job) run() Result {
205224 rep.StepCompleted(1, SetUpJobName, ConclusionSuccess, j.now())
206225
207226 // ---- the steps (StepsRunner) ----
208- for i, st := range steps {
209- j.runStep(i+2, st)
227+ // The queue is the pre-job steps then the steps of the job; the post steps
228+ // registered while they run follow, last registered first
229+ // (StepsRunner.cs:62-75).
230+ num := 2
231+ for _, q := range steps {
232+ j.runStep(num, q)
233+ num++
234+ }
235+ for {
236+ j.mu.Lock()
237+ if len(j.postStack) == 0 {
238+ j.mu.Unlock()
239+ break
240+ }
241+ q := j.postStack[len(j.postStack)-1]
242+ j.postStack = j.postStack[:len(j.postStack)-1]
243+ j.mu.Unlock()
244+ j.runStep(num, q)
245+ num++
210246 }
211247
212248 // ---- Complete job (JobExtension.FinalizeJob) ----
213- last := len(steps) + 2
214- outputs := j.completeJob(last)
249+ outputs := j.completeJob(num)
215250
216251 return j.finish(outputs)
217252 }
@@ -331,7 +366,7 @@ func (j *job) evalMap(m *template.Mapping, env *ordered) ([]Pair, error) {
331366 // setUp parses the workflow and prepares the job: JobExtension.InitializeJob
332367 // (JobExtension.cs:63-182) and the job-start code of JobRunner.cs:142-177. Its
333368 // log lines belong to step 1.
334-func (j *job) setUp() ([]*workflow.Step, error) {
369+func (j *job) setUp() ([]qstep, error) {
335370 cfg := j.cfg
336371 j.out(1, fmt.Sprintf("Current runner version: '%s'", cfg.RunnerVersion))
337372 // Only a self-hosted runner prints its name and machine (JobExtension.cs:
@@ -458,6 +493,10 @@ func (j *job) setUp() ([]*workflow.Step, error) {
458493 }
459494
460495 j.out(1, "Prepare all required actions")
496+ queue, err := j.prepareJob()
497+ if err != nil {
498+ return nil, err
499+ }
461500 // JobExtension.cs:418-422: the display name of the job, which the server
462501 // computes there (message.JobDisplayName). ermite's Job message has no such
463502 // field, so the executor evaluates it (see jobDisplayName).
@@ -474,7 +513,7 @@ func (j *job) setUp() ([]*workflow.Step, error) {
474513 for _, p := range snapshotProcesses() {
475514 j.before[p.key()] = true
476515 }
477- return jb.Steps, nil
516+ return queue, nil
478517 }
479518
480519 // own gives a file or directory created by the executor to the user the steps
@@ -151,6 +151,15 @@ type job struct {
151 // masker hides the secrets in everything reported; it is set once the151 // masker hides the secrets in everything reported; it is set once the
152 // contexts are built, before any step runs.152 // contexts are built, before any step runs.
153 masker atomic.Pointer[masker]153 masker atomic.Pointer[masker]
154+ // extraSecrets are registered after the start (see addSecrets).
155+ extraSecrets []string
156+ // actions holds what the pre-job phase loaded (see prepareActions).
157+ actions *actionState
158+ // postStack is the post steps registered so far, last registered last.
159+ postStack []qstep
160+ // upgradedToNode24 are the actions that asked for node20 and got node24.
161+ upgradedToNode24 []string
162+ jobCtx context.Context
154 }163 }
155 164
156 func (j *job) mask(s string) string { return j.masker.Load().mask(s) }165 func (j *job) mask(s string) string { return j.masker.Load().mask(s) }
@@ -195,6 +204,16 @@ func (j *job) run() Result {
195 // ---- Set up job (JobExtension.InitializeJob) ----204 // ---- Set up job (JobExtension.InitializeJob) ----
196 rep := j.cfg.Reporter205 rep := j.cfg.Reporter
197 rep.StepStarted(1, SetUpJobName, j.now())206 rep.StepStarted(1, SetUpJobName, j.now())
207+ var cancelCtx context.CancelFunc
208+ j.jobCtx, cancelCtx = context.WithCancel(context.Background())
209+ defer cancelCtx()
210+ go func() {
211+ select {
212+ case <-j.cancel.Done():
213+ cancelCtx()
214+ case <-j.jobCtx.Done():
215+ }
216+ }()
198 steps, err := j.setUp()217 steps, err := j.setUp()
199 if err != nil {218 if err != nil {
200 j.errorf(1, "%s", err.Error())219 j.errorf(1, "%s", err.Error())
@@ -205,13 +224,29 @@ func (j *job) run() Result {
205 rep.StepCompleted(1, SetUpJobName, ConclusionSuccess, j.now())224 rep.StepCompleted(1, SetUpJobName, ConclusionSuccess, j.now())
206 225
207 // ---- the steps (StepsRunner) ----226 // ---- the steps (StepsRunner) ----
208- for i, st := range steps {227+ // The queue is the pre-job steps then the steps of the job; the post steps
209- j.runStep(i+2, st)228+ // registered while they run follow, last registered first
229+ // (StepsRunner.cs:62-75).
230+ num := 2
231+ for _, q := range steps {
232+ j.runStep(num, q)
233+ num++
234+ }
235+ for {
236+ j.mu.Lock()
237+ if len(j.postStack) == 0 {
238+ j.mu.Unlock()
239+ break
240+ }
241+ q := j.postStack[len(j.postStack)-1]
242+ j.postStack = j.postStack[:len(j.postStack)-1]
243+ j.mu.Unlock()
244+ j.runStep(num, q)
245+ num++
210 }246 }
211 247
212 // ---- Complete job (JobExtension.FinalizeJob) ----248 // ---- Complete job (JobExtension.FinalizeJob) ----
213- last := len(steps) + 2249+ outputs := j.completeJob(num)
214- outputs := j.completeJob(last)
215 250
216 return j.finish(outputs)251 return j.finish(outputs)
217 }252 }
@@ -331,7 +366,7 @@ func (j *job) evalMap(m *template.Mapping, env *ordered) ([]Pair, error) {
331 // setUp parses the workflow and prepares the job: JobExtension.InitializeJob366 // setUp parses the workflow and prepares the job: JobExtension.InitializeJob
332 // (JobExtension.cs:63-182) and the job-start code of JobRunner.cs:142-177. Its367 // (JobExtension.cs:63-182) and the job-start code of JobRunner.cs:142-177. Its
333 // log lines belong to step 1.368 // log lines belong to step 1.
334-func (j *job) setUp() ([]*workflow.Step, error) {369+func (j *job) setUp() ([]qstep, error) {
335 cfg := j.cfg370 cfg := j.cfg
336 j.out(1, fmt.Sprintf("Current runner version: '%s'", cfg.RunnerVersion))371 j.out(1, fmt.Sprintf("Current runner version: '%s'", cfg.RunnerVersion))
337 // Only a self-hosted runner prints its name and machine (JobExtension.cs:372 // Only a self-hosted runner prints its name and machine (JobExtension.cs:
@@ -458,6 +493,10 @@ func (j *job) setUp() ([]*workflow.Step, error) {
458 }493 }
459 494
460 j.out(1, "Prepare all required actions")495 j.out(1, "Prepare all required actions")
496+ queue, err := j.prepareJob()
497+ if err != nil {
498+ return nil, err
499+ }
461 // JobExtension.cs:418-422: the display name of the job, which the server500 // JobExtension.cs:418-422: the display name of the job, which the server
462 // computes there (message.JobDisplayName). ermite's Job message has no such501 // computes there (message.JobDisplayName). ermite's Job message has no such
463 // field, so the executor evaluates it (see jobDisplayName).502 // field, so the executor evaluates it (see jobDisplayName).
@@ -474,7 +513,7 @@ func (j *job) setUp() ([]*workflow.Step, error) {
474 for _, p := range snapshotProcesses() {513 for _, p := range snapshotProcesses() {
475 j.before[p.key()] = true514 j.before[p.key()] = true
476 }515 }
477- return jb.Steps, nil516+ return queue, nil
478 }517 }
479 518
480 // own gives a file or directory created by the executor to the user the steps519 // own gives a file or directory created by the executor to the user the steps
M internal/executor/job_test.go +12 -17
@@ -194,23 +194,6 @@ jobs:
194194
195195 func TestNotSupportedYet(t *testing.T) {
196196 needTools(t, "bash")
197- rec, res := runLocal(t, wfHead+` steps:
198- - uses: actions/checkout@v4
199- - name: after
200- if: always()
201- run: echo still-runs
202- - run: echo skipped
203-`, "j", nil)
204- if res.Conclusion != ConclusionFailure {
205- t.Fatalf("%+v", res)
206- }
207- u := rec.step(t, "Run actions/checkout@v4")
208- if u.Conclusion != ConclusionFailure || !strings.Contains(logOf(u), "##[error]not supported yet: uses (actions/checkout@v4)") {
209- t.Fatalf("%+v", u)
210- }
211- if rec.step(t, "after").Conclusion != ConclusionSuccess {
212- t.Error("always() steps still run after a failure")
213- }
214197 for _, f := range []string{"container: alpine", "services:\n s:\n image: x"} {
215198 rec2, res2 := runLocal(t, wfHead+" "+f+"\n steps:\n - run: echo hi\n", "j", nil)
216199 if res2.Conclusion != ConclusionFailure || res2.Error == "" || !strings.Contains(logOf(rec2.steps[0]), "not supported yet") {
@@ -219,6 +202,18 @@ func TestNotSupportedYet(t *testing.T) {
219202 }
220203 }
221204
205+// Without a resolver (a host without the feature) an action fails "Set up job"
206+// before any step, with the runner's message.
207+func TestActionWithoutResolverFailsSetUp(t *testing.T) {
208+ rec, res := runLocal(t, wfHead+" steps:\n - uses: actions/checkout@v4\n - run: echo NO\n", "j", nil)
209+ if res.Conclusion != ConclusionFailure || len(rec.steps) != 1 {
210+ t.Fatalf("%+v %v", res, rec.names())
211+ }
212+ if got := logOf(rec.steps[0]); !strings.Contains(got, "##[error]Failed to resolve action download info.") {
213+ t.Fatalf("%s", got)
214+ }
215+}
216+
222217 func TestInvalidWorkflowAndUnknownJob(t *testing.T) {
223218 rec, res := runLocal(t, "on: push\njobs: [", "j", nil)
224219 if res.Conclusion != ConclusionFailure || !strings.Contains(res.Error, "The workflow is not valid") || len(rec.steps) != 1 || !rec.done {
@@ -194,23 +194,6 @@ jobs:
194 194
195 func TestNotSupportedYet(t *testing.T) {195 func TestNotSupportedYet(t *testing.T) {
196 needTools(t, "bash")196 needTools(t, "bash")
197- rec, res := runLocal(t, wfHead+` steps:
198- - uses: actions/checkout@v4
199- - name: after
200- if: always()
201- run: echo still-runs
202- - run: echo skipped
203-`, "j", nil)
204- if res.Conclusion != ConclusionFailure {
205- t.Fatalf("%+v", res)
206- }
207- u := rec.step(t, "Run actions/checkout@v4")
208- if u.Conclusion != ConclusionFailure || !strings.Contains(logOf(u), "##[error]not supported yet: uses (actions/checkout@v4)") {
209- t.Fatalf("%+v", u)
210- }
211- if rec.step(t, "after").Conclusion != ConclusionSuccess {
212- t.Error("always() steps still run after a failure")
213- }
214 for _, f := range []string{"container: alpine", "services:\n s:\n image: x"} {197 for _, f := range []string{"container: alpine", "services:\n s:\n image: x"} {
215 rec2, res2 := runLocal(t, wfHead+" "+f+"\n steps:\n - run: echo hi\n", "j", nil)198 rec2, res2 := runLocal(t, wfHead+" "+f+"\n steps:\n - run: echo hi\n", "j", nil)
216 if res2.Conclusion != ConclusionFailure || res2.Error == "" || !strings.Contains(logOf(rec2.steps[0]), "not supported yet") {199 if res2.Conclusion != ConclusionFailure || res2.Error == "" || !strings.Contains(logOf(rec2.steps[0]), "not supported yet") {
@@ -219,6 +202,18 @@ func TestNotSupportedYet(t *testing.T) {
219 }202 }
220 }203 }
221 204
205+// Without a resolver (a host without the feature) an action fails "Set up job"
206+// before any step, with the runner's message.
207+func TestActionWithoutResolverFailsSetUp(t *testing.T) {
208+ rec, res := runLocal(t, wfHead+" steps:\n - uses: actions/checkout@v4\n - run: echo NO\n", "j", nil)
209+ if res.Conclusion != ConclusionFailure || len(rec.steps) != 1 {
210+ t.Fatalf("%+v %v", res, rec.names())
211+ }
212+ if got := logOf(rec.steps[0]); !strings.Contains(got, "##[error]Failed to resolve action download info.") {
213+ t.Fatalf("%s", got)
214+ }
215+}
216+
222 func TestInvalidWorkflowAndUnknownJob(t *testing.T) {217 func TestInvalidWorkflowAndUnknownJob(t *testing.T) {
223 rec, res := runLocal(t, "on: push\njobs: [", "j", nil)218 rec, res := runLocal(t, "on: push\njobs: [", "j", nil)
224 if res.Conclusion != ConclusionFailure || !strings.Contains(res.Error, "The workflow is not valid") || len(rec.steps) != 1 || !rec.done {219 if res.Conclusion != ConclusionFailure || !strings.Contains(res.Error, "The workflow is not valid") || len(rec.steps) != 1 || !rec.done {
M internal/executor/step.go +42 -166
@@ -5,9 +5,7 @@ package executor
55 import (
66 "context"
77 "errors"
8- "fmt"
98 "strings"
10- "sync"
119
1210 "bnema.dev/ermite-actions/expr"
1311 "bnema.dev/ermite-actions/template"
@@ -17,15 +15,56 @@ import (
1715 // ---------------------------------------------------------------- steps
1816
1917 // stepRun is the state of one step being run; it implements Sink and
20-// commandHost.
18+// commandHost. An embedded step (inside a composite action) is a stepRun too,
19+// with a parent and the number of the outer step: its log lines belong to it.
2120 type stepRun struct {
2221 j *job
2322 num int
2423 st *workflow.Step
24+ n *node
25+ stage stage
26+ parent *stepRun
2527 env *ordered // the step's environment: job env + step env
28+ overrides *ordered // the step env of this step and of its composite parents
2629 cmdResult *stepResult
2730 outcome *stepResult
2831 cmds *commandManager
32+ // result is ExecutionContext.Result of a step that runs embedded steps: it
33+ // gives action_status to them.
34+ result *stepResult
35+ // fc is the file commands of the step: the job's for a step of the job, a
36+ // fork for an embedded one.
37+ fc *FileCommands
38+ // actEnv, when set, is the environment of the process of an action step
39+ // (env context, STATE_ and INPUT_); rawEnv replaces the whole environment
40+ // of the process (docker, whose -e options read it).
41+ actEnv, rawEnv *ordered
42+ rawEnvList []string
43+ quietExit bool
44+ // displayName is the name of the step in the log; def the action it runs.
45+ displayName string
46+ def *actionDef
47+ nodeVersion string
48+}
49+
50+// depth is the number of composite actions around the step.
51+func (s *stepRun) depth() int {
52+ d := 0
53+ for p := s.parent; p != nil; p = p.parent {
54+ d++
55+ }
56+ return d
57+}
58+
59+// procEnv is the environment of the process the step starts.
60+func (s *stepRun) procEnv() []string {
61+ if s.rawEnvList != nil {
62+ return s.rawEnvList
63+ }
64+ if s.actEnv != nil {
65+ return s.j.processEnv(s.actEnv)
66+ }
67+ return s.j.processEnv(s.env)
2968 }
3069
3170 func (s *stepRun) Out(text string) { s.j.out(s.num, text) }
@@ -38,7 +77,6 @@ func (s *stepRun) SetOutput(name, value string) {
3877 s.j.state.SetOutput(s.st.ID, name, value)
3978 s.j.mu.Unlock()
4079 }
41-func (s *stepRun) SaveState(name, value string) {}
4280 func (s *stepRun) SetEnv(name, value string) {
4381 s.j.mu.Lock()
4482 s.j.state.SetEnv(name, value)
@@ -201,157 +239,6 @@ func formatStepName(prefix, name string) string {
201239 return prefix + firstLine(name)
202240 }
203241
204-func (j *job) statusFuncs() workflow.StatusFuncs {
205- st := j.state.Status()
206- return workflow.StatusFuncs{Success: st == "success", Failure: st == "failure", Cancelled: st == "cancelled"}
207-}
208-
209-// markCancelled records the cancellation of the job: job result Canceled and
210-// job.status cancelled (StepsRunner.cs:152-155 and :190-196).
211-func (j *job) markCancelledLocked() {
212- if j.cancelled {
213- return
214- }
215- j.cancelled = true
216- c := resCanceled
217- j.result = &c
218- j.state.SetStatus("cancelled")
219-}
220-
221-func (j *job) runStep(num int, st *workflow.Step) {
222- rep := j.cfg.Reporter
223- s := &stepRun{j: j, num: num, st: st}
224- s.cmds = newCommandManager(s, j.cfg.Debug)
225-
226- // Environment of the step (StepsRunner.cs:108-139): job env, then the step's
227- // own, evaluated against the job env.
228- j.mu.Lock()
229- // github.action is the id of the step, set before the step's env is
230- // evaluated (StepsRunner.cs:118), so that the env and the if of a step see
231- // its own id (the dump step of the recordings reads __run_6, not __run_5).
232- j.state.SetGitHub("action", st.ID)
233- jobEnv := j.state.JobEnv()
234- j.mu.Unlock()
235- s.env = jobEnv.Clone()
236- pairs, envErr := j.evalMap(st.Env, jobEnv)
237- if envErr == nil {
238- for _, p := range pairs {
239- s.env.Set(p.Key, p.Value)
240- }
241- }
242- name := j.displayName(st, s.env)
243- rep.StepStarted(num, name, j.now())
244-
245- complete := func(r stepResult) {
246- res := r
247- j.mu.Lock()
248- // Update the steps context (ExecutionContext.UpdateGlobalStepsContext).
249- outcome := res
250- if s.outcome != nil {
251- outcome = *s.outcome
252- }
253- j.state.SetStepResult(st.ID, string(outcome.conclusion()), string(res.conclusion()))
254- if res == resFailed {
255- m := merge(j.result, resFailed)
256- j.result = &m
257- j.state.SetStatus(statusName(m))
258- }
259- j.mu.Unlock()
260- rep.StepCompleted(num, name, res.conclusion(), j.now())
261- }
262-
263- if envErr != nil {
264- j.errorf(num, "%s", templateNotValid(envErr, true))
265- complete(resFailed)
266- return
267- }
268-
269- // Cancellation seen before the step starts (StepsRunner.cs:188-197).
270- cancelledAtStart := false
271- select {
272- case <-j.cancel.Done():
273- cancelledAtStart = true
274- j.mu.Lock()
275- j.markCancelledLocked()
276- j.mu.Unlock()
277- default:
278- }
279-
280- // Condition (StepsRunner.cs:200-261). On runner shutdown conditions are not
281- // evaluated and the step is skipped.
282- cond := func() (bool, error) {
283- j.mu.Lock()
284- defer j.mu.Unlock()
285- return st.EvaluateIf(context.Background(), j.evalEnv(s.env), j.statusFuncs())
286- }
287- if cancelledAtStart && j.cancel.Reason() == CancelShutdown {
288- complete(resSkipped)
289- return
290- }
291- ok, err := cond()
292- switch {
293- case err != nil:
294- j.errorf(num, "%s", templateNotValid(err, false))
295- complete(resFailed)
296- return
297- case !ok:
298- complete(resSkipped)
299- return
300- }
301-
302- // Cancellation while the step runs (StepsRunner.cs:149-186): the job becomes
303- // cancelled and the step is stopped unless its condition still holds
304- // (always()).
305- stepCancel := make(chan struct{})
306- stopWatch := make(chan struct{})
307- var watch sync.WaitGroup
308- if !cancelledAtStart {
309- watch.Add(1)
310- go func() {
311- defer watch.Done()
312- select {
313- case <-j.cancel.Done():
314- case <-stopWatch:
315- return
316- }
317- j.mu.Lock()
318- j.markCancelledLocked()
319- j.mu.Unlock()
320- keep := false
321- if j.cancel.Reason() != CancelShutdown {
322- keep, _ = cond()
323- }
324- if !keep {
325- close(stepCancel)
326- }
327- }()
328- }
329-
330- r := j.execute(s, stepCancel)
331- close(stopWatch)
332- watch.Wait()
333-
334- // Merge the result of the commands (StepsRunner.cs:347-351).
335- if s.cmdResult != nil {
336- m := merge(&r, *s.cmdResult)
337- r = m
338- }
339- // continue-on-error (ExecutionContext.ApplyContinueOnError, :1422).
340- if r == resFailed && st.ContinueOnError != nil {
341- coe, cerr := template.EvaluateBool(context.Background(), st.ContinueOnError, j.evalEnv(s.env))
342- if cerr != nil {
343- j.errorf(num, "The step failed and an error occurred when attempting to determine whether to continue on error.")
344- j.errorf(num, "%s", cerr.Error())
345- }
346- if coe && cerr == nil {
347- o := resFailed
348- s.outcome = &o
349- r = resSucceeded
350- }
351- }
352- complete(r)
353-}
354-
355242 // statusName is the job.status for a result (TaskResult.ToActionResult).
356243 func statusName(r stepResult) string {
357244 switch r {
@@ -362,14 +249,3 @@ func statusName(r stepResult) string {
362249 }
363250 return "success"
364251 }
365-
366-// notSupported is the failure of a step that uses a feature of a later phase.
367-func notSupported(st *workflow.Step) string {
368- switch {
369- case st.Uses != nil:
370- return fmt.Sprintf("not supported yet: uses (%s)", st.Uses.Value)
371- case st.TimeoutMinutes != nil:
372- return "not supported yet: timeout-minutes"
373- }
374- return ""
375-}
@@ -5,9 +5,7 @@ package executor
5 import (5 import (
6 "context"6 "context"
7 "errors"7 "errors"
8- "fmt"
9 "strings"8 "strings"
10- "sync"
11 9
12 "bnema.dev/ermite-actions/expr"10 "bnema.dev/ermite-actions/expr"
13 "bnema.dev/ermite-actions/template"11 "bnema.dev/ermite-actions/template"
@@ -17,15 +15,56 @@ import (
17 // ---------------------------------------------------------------- steps15 // ---------------------------------------------------------------- steps
18 16
19 // stepRun is the state of one step being run; it implements Sink and17 // stepRun is the state of one step being run; it implements Sink and
20-// commandHost.18+// commandHost. An embedded step (inside a composite action) is a stepRun too,
19+// with a parent and the number of the outer step: its log lines belong to it.
21 type stepRun struct {20 type stepRun struct {
22 j *job21 j *job
23 num int22 num int
24 st *workflow.Step23 st *workflow.Step
24+ n *node
25+ stage stage
26+ parent *stepRun
25 env *ordered // the step's environment: job env + step env27 env *ordered // the step's environment: job env + step env
28+ overrides *ordered // the step env of this step and of its composite parents
26 cmdResult *stepResult29 cmdResult *stepResult
27 outcome *stepResult30 outcome *stepResult
28 cmds *commandManager31 cmds *commandManager
32+ // result is ExecutionContext.Result of a step that runs embedded steps: it
33+ // gives action_status to them.
34+ result *stepResult
35+ // fc is the file commands of the step: the job's for a step of the job, a
36+ // fork for an embedded one.
37+ fc *FileCommands
38+ // actEnv, when set, is the environment of the process of an action step
39+ // (env context, STATE_ and INPUT_); rawEnv replaces the whole environment
40+ // of the process (docker, whose -e options read it).
41+ actEnv, rawEnv *ordered
42+ rawEnvList []string
43+ quietExit bool
44+ // displayName is the name of the step in the log; def the action it runs.
45+ displayName string
46+ def *actionDef
47+ nodeVersion string
48+}
49+
50+// depth is the number of composite actions around the step.
51+func (s *stepRun) depth() int {
52+ d := 0
53+ for p := s.parent; p != nil; p = p.parent {
54+ d++
55+ }
56+ return d
57+}
58+
59+// procEnv is the environment of the process the step starts.
60+func (s *stepRun) procEnv() []string {
61+ if s.rawEnvList != nil {
62+ return s.rawEnvList
63+ }
64+ if s.actEnv != nil {
65+ return s.j.processEnv(s.actEnv)
66+ }
67+ return s.j.processEnv(s.env)
29 }68 }
30 69
31 func (s *stepRun) Out(text string) { s.j.out(s.num, text) }70 func (s *stepRun) Out(text string) { s.j.out(s.num, text) }
@@ -38,7 +77,6 @@ func (s *stepRun) SetOutput(name, value string) {
38 s.j.state.SetOutput(s.st.ID, name, value)77 s.j.state.SetOutput(s.st.ID, name, value)
39 s.j.mu.Unlock()78 s.j.mu.Unlock()
40 }79 }
41-func (s *stepRun) SaveState(name, value string) {}
42 func (s *stepRun) SetEnv(name, value string) {80 func (s *stepRun) SetEnv(name, value string) {
43 s.j.mu.Lock()81 s.j.mu.Lock()
44 s.j.state.SetEnv(name, value)82 s.j.state.SetEnv(name, value)
@@ -201,157 +239,6 @@ func formatStepName(prefix, name string) string {
201 return prefix + firstLine(name)239 return prefix + firstLine(name)
202 }240 }
203 241
204-func (j *job) statusFuncs() workflow.StatusFuncs {
205- st := j.state.Status()
206- return workflow.StatusFuncs{Success: st == "success", Failure: st == "failure", Cancelled: st == "cancelled"}
207-}
208-
209-// markCancelled records the cancellation of the job: job result Canceled and
210-// job.status cancelled (StepsRunner.cs:152-155 and :190-196).
211-func (j *job) markCancelledLocked() {
212- if j.cancelled {
213- return
214- }
215- j.cancelled = true
216- c := resCanceled
217- j.result = &c
218- j.state.SetStatus("cancelled")
219-}
220-
221-func (j *job) runStep(num int, st *workflow.Step) {
222- rep := j.cfg.Reporter
223- s := &stepRun{j: j, num: num, st: st}
224- s.cmds = newCommandManager(s, j.cfg.Debug)
225-
226- // Environment of the step (StepsRunner.cs:108-139): job env, then the step's
227- // own, evaluated against the job env.
228- j.mu.Lock()
229- // github.action is the id of the step, set before the step's env is
230- // evaluated (StepsRunner.cs:118), so that the env and the if of a step see
231- // its own id (the dump step of the recordings reads __run_6, not __run_5).
232- j.state.SetGitHub("action", st.ID)
233- jobEnv := j.state.JobEnv()
234- j.mu.Unlock()
235- s.env = jobEnv.Clone()
236- pairs, envErr := j.evalMap(st.Env, jobEnv)
237- if envErr == nil {
238- for _, p := range pairs {
239- s.env.Set(p.Key, p.Value)
240- }
241- }
242- name := j.displayName(st, s.env)
243- rep.StepStarted(num, name, j.now())
244-
245- complete := func(r stepResult) {
246- res := r
247- j.mu.Lock()
248- // Update the steps context (ExecutionContext.UpdateGlobalStepsContext).
249- outcome := res
250- if s.outcome != nil {
251- outcome = *s.outcome
252- }
253- j.state.SetStepResult(st.ID, string(outcome.conclusion()), string(res.conclusion()))
254- if res == resFailed {
255- m := merge(j.result, resFailed)
256- j.result = &m
257- j.state.SetStatus(statusName(m))
258- }
259- j.mu.Unlock()
260- rep.StepCompleted(num, name, res.conclusion(), j.now())
261- }
262-
263- if envErr != nil {
264- j.errorf(num, "%s", templateNotValid(envErr, true))
265- complete(resFailed)
266- return
267- }
268-
269- // Cancellation seen before the step starts (StepsRunner.cs:188-197).
270- cancelledAtStart := false
271- select {
272- case <-j.cancel.Done():
273- cancelledAtStart = true
274- j.mu.Lock()
275- j.markCancelledLocked()
276- j.mu.Unlock()
277- default:
278- }
279-
280- // Condition (StepsRunner.cs:200-261). On runner shutdown conditions are not
281- // evaluated and the step is skipped.
282- cond := func() (bool, error) {
283- j.mu.Lock()
284- defer j.mu.Unlock()
285- return st.EvaluateIf(context.Background(), j.evalEnv(s.env), j.statusFuncs())
286- }
287- if cancelledAtStart && j.cancel.Reason() == CancelShutdown {
288- complete(resSkipped)
289- return
290- }
291- ok, err := cond()
292- switch {
293- case err != nil:
294- j.errorf(num, "%s", templateNotValid(err, false))
295- complete(resFailed)
296- return
297- case !ok:
298- complete(resSkipped)
299- return
300- }
301-
302- // Cancellation while the step runs (StepsRunner.cs:149-186): the job becomes
303- // cancelled and the step is stopped unless its condition still holds
304- // (always()).
305- stepCancel := make(chan struct{})
306- stopWatch := make(chan struct{})
307- var watch sync.WaitGroup
308- if !cancelledAtStart {
309- watch.Add(1)
310- go func() {
311- defer watch.Done()
312- select {
313- case <-j.cancel.Done():
314- case <-stopWatch:
315- return
316- }
317- j.mu.Lock()
318- j.markCancelledLocked()
319- j.mu.Unlock()
320- keep := false
321- if j.cancel.Reason() != CancelShutdown {
322- keep, _ = cond()
323- }
324- if !keep {
325- close(stepCancel)
326- }
327- }()
328- }
329-
330- r := j.execute(s, stepCancel)
331- close(stopWatch)
332- watch.Wait()
333-
334- // Merge the result of the commands (StepsRunner.cs:347-351).
335- if s.cmdResult != nil {
336- m := merge(&r, *s.cmdResult)
337- r = m
338- }
339- // continue-on-error (ExecutionContext.ApplyContinueOnError, :1422).
340- if r == resFailed && st.ContinueOnError != nil {
341- coe, cerr := template.EvaluateBool(context.Background(), st.ContinueOnError, j.evalEnv(s.env))
342- if cerr != nil {
343- j.errorf(num, "The step failed and an error occurred when attempting to determine whether to continue on error.")
344- j.errorf(num, "%s", cerr.Error())
345- }
346- if coe && cerr == nil {
347- o := resFailed
348- s.outcome = &o
349- r = resSucceeded
350- }
351- }
352- complete(r)
353-}
354-
355 // statusName is the job.status for a result (TaskResult.ToActionResult).242 // statusName is the job.status for a result (TaskResult.ToActionResult).
356 func statusName(r stepResult) string {243 func statusName(r stepResult) string {
357 switch r {244 switch r {
@@ -362,14 +249,3 @@ func statusName(r stepResult) string {
362 }249 }
363 return "success"250 return "success"
364 }251 }
365-
366-// notSupported is the failure of a step that uses a feature of a later phase.
367-func notSupported(st *workflow.Step) string {
368- switch {
369- case st.Uses != nil:
370- return fmt.Sprintf("not supported yet: uses (%s)", st.Uses.Value)
371- case st.TimeoutMinutes != nil:
372- return "not supported yet: timeout-minutes"
373- }
374- return ""
375-}
A internal/executor/step_run.go +318 -0
@@ -0,0 +1,318 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "context"
7+ "sync"
8+
9+ "bnema.dev/ermite-actions/action"
10+ "bnema.dev/ermite-actions/expr"
11+ "bnema.dev/ermite-actions/template"
12+ "bnema.dev/ermite-actions/workflow"
13+)
14+
15+// qstep is a step of the job's queue (StepsRunner's JobSteps): a main step, the
16+// pre or post stage of an action, or a setup step (pull or build an image).
17+type qstep struct {
18+ n *node
19+ stage stage
20+ name string
21+ // cond, for a pre or post stage, replaces the step's own condition (the
22+ // action's pre-if or post-if); setup steps use "success()".
23+ cond string
24+ // setup, when set, is the whole step: it runs instead of the node.
25+ setup func(s *stepRun, cancel <-chan struct{}) stepResult
26+}
27+
28+func (j *job) statusFuncs() workflow.StatusFuncs {
29+ st := j.state.Status()
30+ return workflow.StatusFuncs{Success: st == "success", Failure: st == "failure", Cancelled: st == "cancelled"}
31+}
32+
33+// actionStatus is github.action_status of the steps of a composite action: the
34+// result of the action so far (CompositeActionHandler.cs:246), the job's
35+// cancellation included (:340). j.mu is held.
36+func (s *stepRun) actionStatus() string {
37+ if s.j.cancelled {
38+ return "cancelled"
39+ }
40+ if s.result != nil {
41+ return statusName(*s.result)
42+ }
43+ return "success"
44+}
45+
46+// statusFuncs are the status functions of the step: success() and failure()
47+// of an embedded main step read action_status, the others the job's status
48+// (SuccessFunction.cs:28-45). j.mu is held.
49+func (s *stepRun) statusFuncs() workflow.StatusFuncs {
50+ if s.parent != nil && s.stage == stageMain {
51+ a := s.parent.actionStatus()
52+ return workflow.StatusFuncs{Success: a == "success", Failure: a == "failure", Cancelled: s.j.state.Status() == "cancelled"}
53+ }
54+ return s.j.statusFuncs()
55+}
56+
57+// markCancelled records the cancellation of the job: job result Canceled and
58+// job.status cancelled (StepsRunner.cs:152-155 and :190-196).
59+func (j *job) markCancelledLocked() {
60+ if j.cancelled {
61+ return
62+ }
63+ j.cancelled = true
64+ c := resCanceled
65+ j.result = &c
66+ j.state.SetStatus("cancelled")
67+}
68+
69+// evalCondString evaluates the pre-if or post-if of an action (an expression
70+// without ${{ }}, evaluated as written, EvaluateStepIf on a bare condition).
71+func (j *job) evalCondString(cond string, env *ordered, st workflow.StatusFuncs) (bool, error) {
72+ sch, err := action.Schema()
73+ if err != nil {
74+ return false, err
75+ }
76+ tok := template.NewExpression(template.Origin{}, cond, sch.Info("step-if").WithContexts("vars", "needs"))
77+ te := j.evalEnv(env)
78+ funcs := map[string]expr.Func{}
79+ for k, f := range te.Funcs {
80+ funcs[k] = f
81+ }
82+ for k, f := range st.Funcs() {
83+ funcs[k] = f
84+ }
85+ te.Funcs = funcs
86+ v, err := template.EvaluateExpression(context.Background(), tok, te)
87+ if err != nil {
88+ return false, err
89+ }
90+ return expr.Truthy(v), nil
91+}
92+
93+// stepEnvironment builds the environment of a step (StepsRunner.cs:108-139 and
94+// CompositeActionHandler.cs:233-262): the job's, plus for an embedded step the
95+// env of the steps around it, then its own env, evaluated against that.
96+func (j *job) stepEnvironment(s *stepRun) (*ordered, error) {
97+ j.mu.Lock()
98+ env := j.state.JobEnv()
99+ j.mu.Unlock()
100+ s.overrides = newOrdered()
101+ if p := s.parent; p != nil {
102+ for _, k := range p.overrides.Keys() {
103+ v, _ := p.env.Get(k)
104+ env.Set(k, v)
105+ s.overrides.Set(k, v)
106+ }
107+ }
108+ pairs, err := j.evalStepEnv(s.st, env)
109+ if err != nil {
110+ return env, err
111+ }
112+ for _, p := range pairs {
113+ env.Set(p.Key, p.Value)
114+ s.overrides.Set(p.Key, p.Value)
115+ }
116+ return env, nil
117+}
118+
119+// evalStepEnv evaluates the env of a step: a mapping, or the expression that
120+// stands for one (a step of a composite action may write env: ${{ ... }}).
121+func (j *job) evalStepEnv(st *workflow.Step, env *ordered) ([]Pair, error) {
122+ if st.Env != nil || st.EnvExpression == nil {
123+ return j.evalMap(st.Env, env)
124+ }
125+ v, err := template.Evaluate(context.Background(), st.EnvExpression, j.evalEnv(env))
126+ if err != nil {
127+ return nil, err
128+ }
129+ return pairsOf(v)
130+}
131+
132+func pairsOf(v expr.Value) ([]Pair, error) {
133+ d, ok := v.(*expr.Dictionary)
134+ if !ok {
135+ return nil, nil
136+ }
137+ var out []Pair
138+ for _, p := range d.Pairs() {
139+ if k := p.Value.Kind(); k == expr.KindArray || k == expr.KindDictionary {
140+ return nil, ghErrorf("Unexpected type '%s' encountered while reading 'environment value'. The type 'StringToken' was expected.", k)
141+ }
142+ out = append(out, Pair{p.Key, expr.CoerceString(p.Value)})
143+ }
144+ return out, nil
145+}
146+
147+// runStep runs one step of the queue as a step of the job (its own number, its
148+// own record in the log).
149+func (j *job) runStep(num int, q qstep) {
150+ rep := j.cfg.Reporter
151+ s := &stepRun{j: j, num: num, stage: q.stage}
152+ if q.n != nil {
153+ s.st, s.n = q.n.st, q.n
154+ }
155+ s.cmds = newCommandManager(s, j.cfg.Debug)
156+ name := q.name
157+ if q.setup != nil {
158+ rep.StepStarted(num, name, j.now())
159+ r := j.runGuarded(s, q, true)
160+ rep.StepCompleted(num, name, r.conclusion(), j.now())
161+ return
162+ }
163+ if q.n == nil {
164+ return
165+ }
166+ // github.action is the id of the step, set before the step's env is
167+ // evaluated (StepsRunner.cs:118), so that the env and the if of a step see
168+ // its own id (the dump step of the recordings reads __run_6, not __run_5).
169+ j.mu.Lock()
170+ j.state.SetGitHub("action", q.n.st.ID)
171+ j.mu.Unlock()
172+ env, envErr := j.stepEnvironment(s)
173+ s.env = env
174+ if name == "" {
175+ name = j.displayName(q.n.st, s.env)
176+ if q.stage == stagePre {
177+ name = "Pre " + name
178+ }
179+ }
180+ s.displayName = name
181+ rep.StepStarted(num, name, j.now())
182+ r := j.runGuarded(s, q, envErr == nil)
183+ if envErr != nil {
184+ j.errorf(num, "%s", templateNotValid(envErr, true))
185+ r = resFailed
186+ j.finishStep(s, r)
187+ }
188+ rep.StepCompleted(num, name, r.conclusion(), j.now())
189+}
190+
191+// finishStep records the result of the step in the contexts
192+// (ExecutionContext.UpdateGlobalStepsContext) and, for a step of the job, in
193+// the result of the job.
194+func (j *job) finishStep(s *stepRun, res stepResult) {
195+ j.mu.Lock()
196+ defer j.mu.Unlock()
197+ st := s.st
198+ outcome := res
199+ if s.outcome != nil {
200+ outcome = *s.outcome
201+ }
202+ // Pre and post stages of a step of the job have no context name, and
203+ // generated ids are hidden: only the main stage and embedded steps count.
204+ if s.n != nil && (s.parent != nil || s.stage == stageMain) && st != nil {
205+ j.state.SetStepResult(st.ID, string(outcome.conclusion()), string(res.conclusion()))
206+ }
207+ if s.parent != nil {
208+ if res == resFailed || res == resCanceled {
209+ m := merge(s.parent.result, res)
210+ s.parent.result = &m
211+ }
212+ return
213+ }
214+ if res == resFailed {
215+ m := merge(j.result, resFailed)
216+ j.result = &m
217+ j.state.SetStatus(statusName(m))
218+ }
219+}
220+
221+// runGuarded is the body of a step between its start and its end: the
222+// condition, the run with its cancellation watch, the commands' result,
223+// continue-on-error. ok is false when the env of the step did not evaluate (the
224+// error is reported by the caller).
225+func (j *job) runGuarded(s *stepRun, q qstep, ok bool) stepResult {
226+ num, st := s.num, s.st
227+ complete := func(r stepResult) stepResult { j.finishStep(s, r); return r }
228+ if !ok {
229+ return resFailed
230+ }
231+ // Cancellation seen before the step starts (StepsRunner.cs:188-197).
232+ cancelledAtStart := false
233+ select {
234+ case <-j.cancel.Done():
235+ cancelledAtStart = true
236+ j.mu.Lock()
237+ j.markCancelledLocked()
238+ j.mu.Unlock()
239+ default:
240+ }
241+ cond := func() (bool, error) {
242+ j.mu.Lock()
243+ defer j.mu.Unlock()
244+ switch {
245+ case q.setup != nil:
246+ return j.evalCondString("success()", s.env, j.statusFuncs())
247+ case q.cond != "":
248+ return j.evalCondString(q.cond, s.env, s.statusFuncs())
249+ }
250+ return st.EvaluateIf(context.Background(), j.evalEnv(s.env), s.statusFuncs())
251+ }
252+ // On runner shutdown conditions are not evaluated and the step is skipped.
253+ if cancelledAtStart && j.cancel.Reason() == CancelShutdown {
254+ return complete(resSkipped)
255+ }
256+ run, err := cond()
257+ switch {
258+ case err != nil:
259+ j.errorf(num, "%s", templateNotValid(err, false))
260+ return complete(resFailed)
261+ case !run:
262+ return complete(resSkipped)
263+ }
264+ // Cancellation while the step runs (StepsRunner.cs:149-186): the job becomes
265+ // cancelled and the step is stopped unless its condition still holds
266+ // (always()).
267+ stepCancel := make(chan struct{})
268+ stopWatch := make(chan struct{})
269+ var watch sync.WaitGroup
270+ if !cancelledAtStart {
271+ watch.Add(1)
272+ go func() {
273+ defer watch.Done()
274+ select {
275+ case <-j.cancel.Done():
276+ case <-stopWatch:
277+ return
278+ }
279+ j.mu.Lock()
280+ j.markCancelledLocked()
281+ j.mu.Unlock()
282+ keep := false
283+ if j.cancel.Reason() != CancelShutdown {
284+ keep, _ = cond()
285+ }
286+ if !keep {
287+ close(stepCancel)
288+ }
289+ }()
290+ }
291+ var r stepResult
292+ if q.setup != nil {
293+ r = q.setup(s, stepCancel)
294+ } else {
295+ r = j.execute(s, stepCancel)
296+ }
297+ close(stopWatch)
298+ watch.Wait()
299+
300+ // Merge the result of the commands (StepsRunner.cs:347-351).
301+ if s.cmdResult != nil {
302+ r = merge(&r, *s.cmdResult)
303+ }
304+ // continue-on-error (ExecutionContext.ApplyContinueOnError, :1422).
305+ if r == resFailed && st != nil && st.ContinueOnError != nil {
306+ coe, cerr := template.EvaluateBool(context.Background(), st.ContinueOnError, j.evalEnv(s.env))
307+ if cerr != nil {
308+ j.errorf(num, "The step failed and an error occurred when attempting to determine whether to continue on error.")
309+ j.errorf(num, "%s", cerr.Error())
310+ }
311+ if coe && cerr == nil {
312+ o := resFailed
313+ s.outcome = &o
314+ r = resSucceeded
315+ }
316+ }
317+ return complete(r)
318+}
@@ -0,0 +1,318 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "context"
7+ "sync"
8+
9+ "bnema.dev/ermite-actions/action"
10+ "bnema.dev/ermite-actions/expr"
11+ "bnema.dev/ermite-actions/template"
12+ "bnema.dev/ermite-actions/workflow"
13+)
14+
15+// qstep is a step of the job's queue (StepsRunner's JobSteps): a main step, the
16+// pre or post stage of an action, or a setup step (pull or build an image).
17+type qstep struct {
18+ n *node
19+ stage stage
20+ name string
21+ // cond, for a pre or post stage, replaces the step's own condition (the
22+ // action's pre-if or post-if); setup steps use "success()".
23+ cond string
24+ // setup, when set, is the whole step: it runs instead of the node.
25+ setup func(s *stepRun, cancel <-chan struct{}) stepResult
26+}
27+
28+func (j *job) statusFuncs() workflow.StatusFuncs {
29+ st := j.state.Status()
30+ return workflow.StatusFuncs{Success: st == "success", Failure: st == "failure", Cancelled: st == "cancelled"}
31+}
32+
33+// actionStatus is github.action_status of the steps of a composite action: the
34+// result of the action so far (CompositeActionHandler.cs:246), the job's
35+// cancellation included (:340). j.mu is held.
36+func (s *stepRun) actionStatus() string {
37+ if s.j.cancelled {
38+ return "cancelled"
39+ }
40+ if s.result != nil {
41+ return statusName(*s.result)
42+ }
43+ return "success"
44+}
45+
46+// statusFuncs are the status functions of the step: success() and failure()
47+// of an embedded main step read action_status, the others the job's status
48+// (SuccessFunction.cs:28-45). j.mu is held.
49+func (s *stepRun) statusFuncs() workflow.StatusFuncs {
50+ if s.parent != nil && s.stage == stageMain {
51+ a := s.parent.actionStatus()
52+ return workflow.StatusFuncs{Success: a == "success", Failure: a == "failure", Cancelled: s.j.state.Status() == "cancelled"}
53+ }
54+ return s.j.statusFuncs()
55+}
56+
57+// markCancelled records the cancellation of the job: job result Canceled and
58+// job.status cancelled (StepsRunner.cs:152-155 and :190-196).
59+func (j *job) markCancelledLocked() {
60+ if j.cancelled {
61+ return
62+ }
63+ j.cancelled = true
64+ c := resCanceled
65+ j.result = &c
66+ j.state.SetStatus("cancelled")
67+}
68+
69+// evalCondString evaluates the pre-if or post-if of an action (an expression
70+// without ${{ }}, evaluated as written, EvaluateStepIf on a bare condition).
71+func (j *job) evalCondString(cond string, env *ordered, st workflow.StatusFuncs) (bool, error) {
72+ sch, err := action.Schema()
73+ if err != nil {
74+ return false, err
75+ }
76+ tok := template.NewExpression(template.Origin{}, cond, sch.Info("step-if").WithContexts("vars", "needs"))
77+ te := j.evalEnv(env)
78+ funcs := map[string]expr.Func{}
79+ for k, f := range te.Funcs {
80+ funcs[k] = f
81+ }
82+ for k, f := range st.Funcs() {
83+ funcs[k] = f
84+ }
85+ te.Funcs = funcs
86+ v, err := template.EvaluateExpression(context.Background(), tok, te)
87+ if err != nil {
88+ return false, err
89+ }
90+ return expr.Truthy(v), nil
91+}
92+
93+// stepEnvironment builds the environment of a step (StepsRunner.cs:108-139 and
94+// CompositeActionHandler.cs:233-262): the job's, plus for an embedded step the
95+// env of the steps around it, then its own env, evaluated against that.
96+func (j *job) stepEnvironment(s *stepRun) (*ordered, error) {
97+ j.mu.Lock()
98+ env := j.state.JobEnv()
99+ j.mu.Unlock()
100+ s.overrides = newOrdered()
101+ if p := s.parent; p != nil {
102+ for _, k := range p.overrides.Keys() {
103+ v, _ := p.env.Get(k)
104+ env.Set(k, v)
105+ s.overrides.Set(k, v)
106+ }
107+ }
108+ pairs, err := j.evalStepEnv(s.st, env)
109+ if err != nil {
110+ return env, err
111+ }
112+ for _, p := range pairs {
113+ env.Set(p.Key, p.Value)
114+ s.overrides.Set(p.Key, p.Value)
115+ }
116+ return env, nil
117+}
118+
119+// evalStepEnv evaluates the env of a step: a mapping, or the expression that
120+// stands for one (a step of a composite action may write env: ${{ ... }}).
121+func (j *job) evalStepEnv(st *workflow.Step, env *ordered) ([]Pair, error) {
122+ if st.Env != nil || st.EnvExpression == nil {
123+ return j.evalMap(st.Env, env)
124+ }
125+ v, err := template.Evaluate(context.Background(), st.EnvExpression, j.evalEnv(env))
126+ if err != nil {
127+ return nil, err
128+ }
129+ return pairsOf(v)
130+}
131+
132+func pairsOf(v expr.Value) ([]Pair, error) {
133+ d, ok := v.(*expr.Dictionary)
134+ if !ok {
135+ return nil, nil
136+ }
137+ var out []Pair
138+ for _, p := range d.Pairs() {
139+ if k := p.Value.Kind(); k == expr.KindArray || k == expr.KindDictionary {
140+ return nil, ghErrorf("Unexpected type '%s' encountered while reading 'environment value'. The type 'StringToken' was expected.", k)
141+ }
142+ out = append(out, Pair{p.Key, expr.CoerceString(p.Value)})
143+ }
144+ return out, nil
145+}
146+
147+// runStep runs one step of the queue as a step of the job (its own number, its
148+// own record in the log).
149+func (j *job) runStep(num int, q qstep) {
150+ rep := j.cfg.Reporter
151+ s := &stepRun{j: j, num: num, stage: q.stage}
152+ if q.n != nil {
153+ s.st, s.n = q.n.st, q.n
154+ }
155+ s.cmds = newCommandManager(s, j.cfg.Debug)
156+ name := q.name
157+ if q.setup != nil {
158+ rep.StepStarted(num, name, j.now())
159+ r := j.runGuarded(s, q, true)
160+ rep.StepCompleted(num, name, r.conclusion(), j.now())
161+ return
162+ }
163+ if q.n == nil {
164+ return
165+ }
166+ // github.action is the id of the step, set before the step's env is
167+ // evaluated (StepsRunner.cs:118), so that the env and the if of a step see
168+ // its own id (the dump step of the recordings reads __run_6, not __run_5).
169+ j.mu.Lock()
170+ j.state.SetGitHub("action", q.n.st.ID)
171+ j.mu.Unlock()
172+ env, envErr := j.stepEnvironment(s)
173+ s.env = env
174+ if name == "" {
175+ name = j.displayName(q.n.st, s.env)
176+ if q.stage == stagePre {
177+ name = "Pre " + name
178+ }
179+ }
180+ s.displayName = name
181+ rep.StepStarted(num, name, j.now())
182+ r := j.runGuarded(s, q, envErr == nil)
183+ if envErr != nil {
184+ j.errorf(num, "%s", templateNotValid(envErr, true))
185+ r = resFailed
186+ j.finishStep(s, r)
187+ }
188+ rep.StepCompleted(num, name, r.conclusion(), j.now())
189+}
190+
191+// finishStep records the result of the step in the contexts
192+// (ExecutionContext.UpdateGlobalStepsContext) and, for a step of the job, in
193+// the result of the job.
194+func (j *job) finishStep(s *stepRun, res stepResult) {
195+ j.mu.Lock()
196+ defer j.mu.Unlock()
197+ st := s.st
198+ outcome := res
199+ if s.outcome != nil {
200+ outcome = *s.outcome
201+ }
202+ // Pre and post stages of a step of the job have no context name, and
203+ // generated ids are hidden: only the main stage and embedded steps count.
204+ if s.n != nil && (s.parent != nil || s.stage == stageMain) && st != nil {
205+ j.state.SetStepResult(st.ID, string(outcome.conclusion()), string(res.conclusion()))
206+ }
207+ if s.parent != nil {
208+ if res == resFailed || res == resCanceled {
209+ m := merge(s.parent.result, res)
210+ s.parent.result = &m
211+ }
212+ return
213+ }
214+ if res == resFailed {
215+ m := merge(j.result, resFailed)
216+ j.result = &m
217+ j.state.SetStatus(statusName(m))
218+ }
219+}
220+
221+// runGuarded is the body of a step between its start and its end: the
222+// condition, the run with its cancellation watch, the commands' result,
223+// continue-on-error. ok is false when the env of the step did not evaluate (the
224+// error is reported by the caller).
225+func (j *job) runGuarded(s *stepRun, q qstep, ok bool) stepResult {
226+ num, st := s.num, s.st
227+ complete := func(r stepResult) stepResult { j.finishStep(s, r); return r }
228+ if !ok {
229+ return resFailed
230+ }
231+ // Cancellation seen before the step starts (StepsRunner.cs:188-197).
232+ cancelledAtStart := false
233+ select {
234+ case <-j.cancel.Done():
235+ cancelledAtStart = true
236+ j.mu.Lock()
237+ j.markCancelledLocked()
238+ j.mu.Unlock()
239+ default:
240+ }
241+ cond := func() (bool, error) {
242+ j.mu.Lock()
243+ defer j.mu.Unlock()
244+ switch {
245+ case q.setup != nil:
246+ return j.evalCondString("success()", s.env, j.statusFuncs())
247+ case q.cond != "":
248+ return j.evalCondString(q.cond, s.env, s.statusFuncs())
249+ }
250+ return st.EvaluateIf(context.Background(), j.evalEnv(s.env), s.statusFuncs())
251+ }
252+ // On runner shutdown conditions are not evaluated and the step is skipped.
253+ if cancelledAtStart && j.cancel.Reason() == CancelShutdown {
254+ return complete(resSkipped)
255+ }
256+ run, err := cond()
257+ switch {
258+ case err != nil:
259+ j.errorf(num, "%s", templateNotValid(err, false))
260+ return complete(resFailed)
261+ case !run:
262+ return complete(resSkipped)
263+ }
264+ // Cancellation while the step runs (StepsRunner.cs:149-186): the job becomes
265+ // cancelled and the step is stopped unless its condition still holds
266+ // (always()).
267+ stepCancel := make(chan struct{})
268+ stopWatch := make(chan struct{})
269+ var watch sync.WaitGroup
270+ if !cancelledAtStart {
271+ watch.Add(1)
272+ go func() {
273+ defer watch.Done()
274+ select {
275+ case <-j.cancel.Done():
276+ case <-stopWatch:
277+ return
278+ }
279+ j.mu.Lock()
280+ j.markCancelledLocked()
281+ j.mu.Unlock()
282+ keep := false
283+ if j.cancel.Reason() != CancelShutdown {
284+ keep, _ = cond()
285+ }
286+ if !keep {
287+ close(stepCancel)
288+ }
289+ }()
290+ }
291+ var r stepResult
292+ if q.setup != nil {
293+ r = q.setup(s, stepCancel)
294+ } else {
295+ r = j.execute(s, stepCancel)
296+ }
297+ close(stopWatch)
298+ watch.Wait()
299+
300+ // Merge the result of the commands (StepsRunner.cs:347-351).
301+ if s.cmdResult != nil {
302+ r = merge(&r, *s.cmdResult)
303+ }
304+ // continue-on-error (ExecutionContext.ApplyContinueOnError, :1422).
305+ if r == resFailed && st != nil && st.ContinueOnError != nil {
306+ coe, cerr := template.EvaluateBool(context.Background(), st.ContinueOnError, j.evalEnv(s.env))
307+ if cerr != nil {
308+ j.errorf(num, "The step failed and an error occurred when attempting to determine whether to continue on error.")
309+ j.errorf(num, "%s", cerr.Error())
310+ }
311+ if coe && cerr == nil {
312+ o := resFailed
313+ s.outcome = &o
314+ r = resSucceeded
315+ }
316+ }
317+ return complete(r)
318+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 4

Tags 11

↑↓ moveenter openesc close