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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 ( | |||
| 6 | 6 | "context" | |
| 7 | 7 | "net/http" | |
| 8 | 8 | "strings" | |
| 9 | + | "time" | |
| 9 | 10 | ||
| 10 | 11 | "bnema.dev/ermite-actions/action" | |
| 11 | 12 | "bnema.dev/ermite-actions/workflow" | |
| @@ -40,6 +41,9 @@ type ActionsConfig struct { | |||
| 40 | 41 | // (HandlerFactory.cs, NodeUtil.DetermineActionsNodeVersion): ermite has no | |
| 41 | 42 | // feature flags, so the default is pinned here. | |
| 42 | 43 | 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 | 49 | const defaultExternalsDir = "/home/runner/externals" | |
| @@ -57,20 +61,27 @@ const ( | |||
| 57 | 61 | // (Runner.Common/Constants.cs:46). | |
| 58 | 62 | const compositeMaxDepth = 9 | |
| 59 | 63 | ||
| 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). | |
| 62 | 75 | 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://). | |
| 66 | 78 | 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 | |
| 74 | 85 | } | |
| 75 | 86 | ||
| 76 | 87 | func (d *actionDef) runs() *action.Runs { | |
| @@ -80,42 +91,8 @@ func (d *actionDef) runs() *action.Runs { | |||
| 80 | 91 | 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 | 94 | // 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 | 96 | func (d *actionDef) preIf() string { return condOrAlways(d.runs(), true) } | |
| 120 | 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 | 110 | 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 | + | } | |
| @@ -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 no | 41 | // (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 bool | 43 | 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 = 9 | 62 | 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.Uses | 76 | + | 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 string | 78 | dir string | ||
| 67 | - | act *action.Action // nil for docker:// and Dockerfile-only actions | 79 | + | 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 string | 82 | + | image string |
| 71 | - | dockerfile bool | 83 | + | // 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.Runs | 91 | 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 the | 94 | // 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 c | 110 | 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) { | |||
| 33 | 33 | } | |
| 34 | 34 | ||
| 35 | 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) } | |
| 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) } | |
| 38 | 45 | ||
| 39 | 46 | func (o *ordered) Clone() *ordered { | |
| 40 | 47 | c := newOrdered() | |
| @@ -213,9 +220,10 @@ type State struct { | |||
| 213 | 220 | // env is Global.EnvironmentVariables: the job-level environment and what | |
| 214 | 221 | // GITHUB_ENV adds. A step's own env is not in it. | |
| 215 | 222 | 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 | |
| 219 | 227 | // prepend is Global.PrependPath in the order of the GITHUB_PATH entries. | |
| 220 | 228 | prepend []string | |
| 221 | 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 | 251 | 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 | 257 | // github: token first, then what the server gave (ExecutionContext.cs:1023-1037). | |
| 248 | 258 | // 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 | 417 | func stepIDVisible(id string) bool { return id != "" && !strings.HasPrefix(id, "__") } | |
| 408 | 418 | ||
| 409 | 419 | func (s *State) step(id string) *stepCtx { | |
| 410 | - | c, ok := s.steps[id] | |
| 420 | + | c, ok := s.cur.steps[id] | |
| 411 | 421 | if !ok { | |
| 412 | 422 | 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) | |
| 415 | 425 | } | |
| 416 | 426 | return c | |
| 417 | 427 | } | |
| @@ -437,7 +447,7 @@ func (s *State) SetStepResult(id, outcome, conclusion string) { | |||
| 437 | 447 | ||
| 438 | 448 | // Output returns an output of a step ("" when absent). | |
| 439 | 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 | 451 | v, _ := c.outputs.Get(name) | |
| 442 | 452 | return v | |
| 443 | 453 | } | |
| @@ -446,8 +456,8 @@ func (s *State) Output(id, name string) string { | |||
| 446 | 456 | ||
| 447 | 457 | func (s *State) stepsValue() *expr.Dictionary { | |
| 448 | 458 | 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] | |
| 451 | 461 | sd := expr.NewDictionary() | |
| 452 | 462 | sd.Add("outputs", c.outputs.Dict()) | |
| 453 | 463 | if c.outcome != "" { | |
| @@ -477,6 +487,9 @@ func (s *State) Values(env *ordered) map[string]expr.Value { | |||
| 477 | 487 | for k, v := range s.other { | |
| 478 | 488 | m[k] = v | |
| 479 | 489 | } | |
| 490 | + | if s.cur.inputs != nil { | |
| 491 | + | m["inputs"] = s.cur.inputs | |
| 492 | + | } | |
| 480 | 493 | return m | |
| 481 | 494 | } | |
| 482 | 495 | ||
| @@ -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 what | 220 | // 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 *ordered | 222 | 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 []string | 224 | + | // action's steps see their own (see PushScope). |
| 218 | - | steps map[string]*stepCtx | 225 | + | 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 []string | 228 | 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 = d | 251 | 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's | 258 | // 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] = c | 423 | + | 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 c | 426 | 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 v | 452 | 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] = v | 488 | m[k] = v | ||
| 479 | } | 489 | } | ||
| 490 | + | if s.cur.inputs != nil { | |||
| 491 | + | m["inputs"] = s.cur.inputs | |||
| 492 | + | } | |||
| 480 | return m | 493 | return m | ||
| 481 | } | 494 | } | ||
| 482 | 495 | ||||
M internal/executor/exec_step.go +37 -19
| @@ -7,19 +7,13 @@ import ( | |||
| 7 | 7 | "path/filepath" | |
| 8 | 8 | "strings" | |
| 9 | 9 | ||
| 10 | - | "bnema.dev/ermite-actions/workflow" | |
| 11 | 10 | ) | |
| 12 | 11 | ||
| 13 | 12 | // execute is ActionRunner.RunAsync plus ScriptHandler for one run step. | |
| 14 | 13 | func (j *job) execute(s *stepRun, stepCancel <-chan struct{}) stepResult { | |
| 15 | 14 | 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) | |
| 23 | 17 | } | |
| 24 | 18 | fail := func(err error) stepResult { j.errorf(num, "%s", err.Error()); return resFailed } | |
| 25 | 19 | failTemplate := func(err error) stepResult { | |
| @@ -27,11 +21,11 @@ func (j *job) execute(s *stepRun, stepCancel <-chan struct{}) stepResult { | |||
| 27 | 21 | return resFailed | |
| 28 | 22 | } | |
| 29 | 23 | ||
| 30 | - | if err := j.prepareFileCommands(st); err != nil { | |
| 24 | + | if err := j.prepareFileCommands(s); err != nil { | |
| 31 | 25 | return fail(err) | |
| 32 | 26 | } | |
| 33 | 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 | 30 | in, err := j.scriptInputs(s) | |
| 37 | 31 | if err != nil { | |
| @@ -67,7 +61,10 @@ func (j *job) scriptInputs(s *stepRun) (scriptIn, error) { | |||
| 67 | 61 | if in.shell, err = j.evalString(st.Shell, s.env); err != nil { | |
| 68 | 62 | 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 | 68 | in.shell = j.runDefaults.shell | |
| 72 | 69 | } | |
| 73 | 70 | if st.WorkingDirectory != nil { | |
| @@ -76,7 +73,7 @@ func (j *job) scriptInputs(s *stepRun) (scriptIn, error) { | |||
| 76 | 73 | return in, err | |
| 77 | 74 | } | |
| 78 | 75 | } | |
| 79 | - | if !in.hasWorkingDirectory { | |
| 76 | + | if !in.hasWorkingDirectory && top { | |
| 80 | 77 | in.workingDirectory = j.runDefaults.workingDirectory | |
| 81 | 78 | } | |
| 82 | 79 | return in, nil | |
| @@ -85,18 +82,25 @@ func (j *job) scriptInputs(s *stepRun) (scriptIn, error) { | |||
| 85 | 82 | // prepareFileCommands creates the files of the step, puts their paths in the | |
| 86 | 83 | // github context (FileCommandManager.InitializeFiles) and writes event.json | |
| 87 | 84 | // (ExecutionContext.WriteWebhookPayload). | |
| 88 | - | func (j *job) prepareFileCommands(st *workflow.Step) error { | |
| 85 | + | func (j *job) prepareFileCommands(s *stepRun) error { | |
| 89 | 86 | j.mu.Lock() | |
| 90 | 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 | 92 | if err != nil { | |
| 93 | 93 | return err | |
| 94 | 94 | } | |
| 95 | 95 | for _, c := range fileCommands { | |
| 96 | 96 | j.state.SetGitHub(c.ContextName, files[envVarName(c.ContextName)]) | |
| 97 | 97 | } | |
| 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 | + | } | |
| 100 | 104 | if ev := j.state.EventJSON(); ev != "" { | |
| 101 | 105 | if p, err := j.writeEvent(ev); err == nil { | |
| 102 | 106 | j.state.SetGitHub("event_path", p) | |
| @@ -191,9 +195,18 @@ func (j *job) processEnv(stepEnv *ordered) []string { | |||
| 191 | 195 | // startProcess runs the script and turns how it ended into the result of the | |
| 192 | 196 | // step. | |
| 193 | 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 | 207 | num := s.num | |
| 195 | 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 | 210 | OnLine: func(line string) { | |
| 198 | 211 | if strings.Contains(line, "##[start-action") || strings.Contains(line, "##[end-action") { | |
| 199 | 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 | 220 | WaitDelay: j.cfg.WaitDelay, Signal: j.cfg.Signal, | |
| 208 | 221 | }) | |
| 209 | 222 | if err != nil { | |
| 210 | - | j.errorf(num, "%s", startError(plan.CommandPath, dir, err)) | |
| 223 | + | j.errorf(num, "%s", startError(shown, dir, err)) | |
| 211 | 224 | return resFailed | |
| 212 | 225 | } | |
| 213 | 226 | if res.Cancelled { | |
| @@ -217,8 +230,13 @@ func (j *job) startProcess(s *stepRun, plan ShellPlan, dir string, argv []string | |||
| 217 | 230 | j.errorf(num, "The operation was canceled.") | |
| 218 | 231 | return resCanceled | |
| 219 | 232 | } | |
| 233 | + | if exitDebug != "" { | |
| 234 | + | j.debug(num, fmt.Sprintf(exitDebug, res.ExitCode)) | |
| 235 | + | } | |
| 220 | 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 | 240 | return resFailed | |
| 223 | 241 | } | |
| 224 | 242 | 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.st | 14 | 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 resFailed | 21 | 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, err | 62 | 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.shell | 68 | 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, err | 73 | return in, err | ||
| 77 | } | 74 | } | ||
| 78 | } | 75 | } | ||
| 79 | - | if !in.hasWorkingDirectory { | 76 | + | if !in.hasWorkingDirectory && top { |
| 80 | in.workingDirectory = j.runDefaults.workingDirectory | 77 | in.workingDirectory = j.runDefaults.workingDirectory | ||
| 81 | } | 78 | } | ||
| 82 | return in, nil | 79 | 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 the | 82 | // prepareFileCommands creates the files of the step, puts their paths in the | ||
| 86 | // github context (FileCommandManager.InitializeFiles) and writes event.json | 83 | // 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 err | 93 | 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 the | 195 | // 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.num | 207 | 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 resFailed | 224 | 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 resCanceled | 231 | 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 resFailed | 240 | return resFailed | ||
| 223 | } | 241 | } | ||
| 224 | return resSucceeded | 242 | 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 { | |||
| 151 | 151 | // masker hides the secrets in everything reported; it is set once the | |
| 152 | 152 | // contexts are built, before any step runs. | |
| 153 | 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 | 165 | func (j *job) mask(s string) string { return j.masker.Load().mask(s) } | |
| @@ -195,6 +204,16 @@ func (j *job) run() Result { | |||
| 195 | 204 | // ---- Set up job (JobExtension.InitializeJob) ---- | |
| 196 | 205 | rep := j.cfg.Reporter | |
| 197 | 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 | 217 | steps, err := j.setUp() | |
| 199 | 218 | if err != nil { | |
| 200 | 219 | j.errorf(1, "%s", err.Error()) | |
| @@ -205,13 +224,29 @@ func (j *job) run() Result { | |||
| 205 | 224 | rep.StepCompleted(1, SetUpJobName, ConclusionSuccess, j.now()) | |
| 206 | 225 | ||
| 207 | 226 | // ---- 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++ | |
| 210 | 246 | } | |
| 211 | 247 | ||
| 212 | 248 | // ---- Complete job (JobExtension.FinalizeJob) ---- | |
| 213 | - | last := len(steps) + 2 | |
| 214 | - | outputs := j.completeJob(last) | |
| 249 | + | outputs := j.completeJob(num) | |
| 215 | 250 | ||
| 216 | 251 | return j.finish(outputs) | |
| 217 | 252 | } | |
| @@ -331,7 +366,7 @@ func (j *job) evalMap(m *template.Mapping, env *ordered) ([]Pair, error) { | |||
| 331 | 366 | // setUp parses the workflow and prepares the job: JobExtension.InitializeJob | |
| 332 | 367 | // (JobExtension.cs:63-182) and the job-start code of JobRunner.cs:142-177. Its | |
| 333 | 368 | // log lines belong to step 1. | |
| 334 | - | func (j *job) setUp() ([]*workflow.Step, error) { | |
| 369 | + | func (j *job) setUp() ([]qstep, error) { | |
| 335 | 370 | cfg := j.cfg | |
| 336 | 371 | j.out(1, fmt.Sprintf("Current runner version: '%s'", cfg.RunnerVersion)) | |
| 337 | 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 | 495 | j.out(1, "Prepare all required actions") | |
| 496 | + | queue, err := j.prepareJob() | |
| 497 | + | if err != nil { | |
| 498 | + | return nil, err | |
| 499 | + | } | |
| 461 | 500 | // JobExtension.cs:418-422: the display name of the job, which the server | |
| 462 | 501 | // computes there (message.JobDisplayName). ermite's Job message has no such | |
| 463 | 502 | // field, so the executor evaluates it (see jobDisplayName). | |
| @@ -474,7 +513,7 @@ func (j *job) setUp() ([]*workflow.Step, error) { | |||
| 474 | 513 | for _, p := range snapshotProcesses() { | |
| 475 | 514 | j.before[p.key()] = true | |
| 476 | 515 | } | |
| 477 | - | return jb.Steps, nil | |
| 516 | + | return queue, nil | |
| 478 | 517 | } | |
| 479 | 518 | ||
| 480 | 519 | // 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 the | 151 | // 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.Reporter | 205 | 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) + 2 | 249 | + | 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.InitializeJob | 366 | // 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. Its | 367 | // (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.cfg | 370 | 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 server | 500 | // JobExtension.cs:418-422: the display name of the job, which the server | ||
| 462 | // computes there (message.JobDisplayName). ermite's Job message has no such | 501 | // 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()] = true | 514 | j.before[p.key()] = true | ||
| 476 | } | 515 | } | ||
| 477 | - | return jb.Steps, nil | 516 | + | return queue, nil |
| 478 | } | 517 | } | ||
| 479 | 518 | ||||
| 480 | // own gives a file or directory created by the executor to the user the steps | 519 | // 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: | |||
| 194 | 194 | ||
| 195 | 195 | func TestNotSupportedYet(t *testing.T) { | |
| 196 | 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 | 197 | for _, f := range []string{"container: alpine", "services:\n s:\n image: x"} { | |
| 215 | 198 | rec2, res2 := runLocal(t, wfHead+" "+f+"\n steps:\n - run: echo hi\n", "j", nil) | |
| 216 | 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 | 217 | func TestInvalidWorkflowAndUnknownJob(t *testing.T) { | |
| 223 | 218 | rec, res := runLocal(t, "on: push\njobs: [", "j", nil) | |
| 224 | 219 | 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 | |||
| 5 | 5 | import ( | |
| 6 | 6 | "context" | |
| 7 | 7 | "errors" | |
| 8 | - | "fmt" | |
| 9 | 8 | "strings" | |
| 10 | - | "sync" | |
| 11 | 9 | ||
| 12 | 10 | "bnema.dev/ermite-actions/expr" | |
| 13 | 11 | "bnema.dev/ermite-actions/template" | |
| @@ -17,15 +15,56 @@ import ( | |||
| 17 | 15 | // ---------------------------------------------------------------- steps | |
| 18 | 16 | ||
| 19 | 17 | // 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 | 20 | type stepRun struct { | |
| 22 | 21 | j *job | |
| 23 | 22 | num int | |
| 24 | 23 | st *workflow.Step | |
| 24 | + | n *node | |
| 25 | + | stage stage | |
| 26 | + | parent *stepRun | |
| 25 | 27 | 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 | 29 | cmdResult *stepResult | |
| 27 | 30 | outcome *stepResult | |
| 28 | 31 | 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 | 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 | 77 | s.j.state.SetOutput(s.st.ID, name, value) | |
| 39 | 78 | s.j.mu.Unlock() | |
| 40 | 79 | } | |
| 41 | - | func (s *stepRun) SaveState(name, value string) {} | |
| 42 | 80 | func (s *stepRun) SetEnv(name, value string) { | |
| 43 | 81 | s.j.mu.Lock() | |
| 44 | 82 | s.j.state.SetEnv(name, value) | |
| @@ -201,157 +239,6 @@ func formatStepName(prefix, name string) string { | |||
| 201 | 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 | 242 | // statusName is the job.status for a result (TaskResult.ToActionResult). | |
| 356 | 243 | func statusName(r stepResult) string { | |
| 357 | 244 | switch r { | |
| @@ -362,14 +249,3 @@ func statusName(r stepResult) string { | |||
| 362 | 249 | } | |
| 363 | 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 | - | } | |
| @@ -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 | // ---------------------------------------------------------------- steps | 15 | // ---------------------------------------------------------------- steps | ||
| 18 | 16 | ||||
| 19 | // stepRun is the state of one step being run; it implements Sink and | 17 | // 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 *job | 21 | j *job | ||
| 23 | num int | 22 | num int | ||
| 24 | st *workflow.Step | 23 | st *workflow.Step | ||
| 24 | + | n *node | |||
| 25 | + | stage stage | |||
| 26 | + | parent *stepRun | |||
| 25 | env *ordered // the step's environment: job env + step env | 27 | 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 *stepResult | 29 | cmdResult *stepResult | ||
| 27 | outcome *stepResult | 30 | outcome *stepResult | ||
| 28 | cmds *commandManager | 31 | 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 | + | } | |||