1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package usecase
4
5import (
6 "context"
7 "crypto/sha256"
8 "encoding/hex"
9 "encoding/json"
10 "errors"
11 "fmt"
12 "io"
13 "slices"
14 "strconv"
15 "strings"
16
17 "go.yaml.in/yaml/v3"
18
19 "bnema.dev/ermite-actions/expr"
20 "bnema.dev/ermite-actions/template"
21 "bnema.dev/ermite-actions/workflow"
22 "bnema.dev/ermite/internal/domain"
23)
24
25// Reusable workflows (CI-3, D7). ermite owns the job graph, so a job with
26// "uses:" is expanded at planning, not by the runner:
27//
28// - the job becomes a call record (one per matrix leg) that never runs; it ends
29// when the jobs of its scope have, with their aggregate conclusion, and its
30// outputs are the callee's "on.workflow_call.outputs";
31// - each job of the called workflow becomes a record of that scope, keyed
32// "<caller key>/<callee id>", named "<caller name> / <callee name>", planned
33// when the callee's own "needs" have ended (the caller's "needs" ended
34// before the call was expanded, so they hold for every callee root);
35// - the called file is kept in the call record, so every job of the call runs
36// from the file of the commit that was resolved, and the runner is given that
37// file and the callee job id.
38//
39// Texts are the ones of actions/runner (ReusableWorkflowsLoader.cs,
40// WorkflowTemplateConverter.cs, PermissionsHelper.cs) and languageservices
41// (validate.ts) where they exist.
42
43// maxReusableWorkflowDepth is the nesting limit of reusable workflows
44// (languageservices workflow-parser/src/model/convert.ts:49, 4; the runner's
45// check is ReusableWorkflowsLoader.LoadRecursive).
46const maxReusableWorkflowDepth = 4
47
48// maxJobIndex is the highest job position a check id can hold (checkIndexBits).
49const maxJobIndex = 1<<checkIndexBits - 1
50
51// callRef is a parsed "uses:" of a job.
52type callRef struct {
53 local bool
54 owner, repo string
55 path string // .github/workflows/x.yml
56 ref string
57}
58
59// parseCallRef reads the "uses:" of a job calling a workflow. The texts are
60// languageservices' (validate.ts validateWorkflowUsesFormat).
61func parseCallRef(uses string) (callRef, string) {
62 bad := func(reason string) (callRef, string) {
63 return callRef{}, "Invalid workflow reference '" + uses + "': " + reason
64 }
65 if strings.HasPrefix(uses, "./.github/workflows/") {
66 switch {
67 case strings.Contains(uses, "@"):
68 return bad("cannot specify version when calling local workflows")
69 case !strings.HasSuffix(uses, ".yml") && !strings.HasSuffix(uses, ".yaml"):
70 return bad("workflow file should have either a '.yml' or '.yaml' file extension")
71 case len(strings.Split(uses, "/")) != 4:
72 return bad("workflows must be defined at the top level of the .github/workflows/ directory")
73 case strings.HasSuffix(uses, "/.yml") || strings.HasSuffix(uses, "/.yaml"):
74 return bad("invalid workflow file name")
75 }
76 return callRef{local: true, path: strings.TrimPrefix(uses, "./")}, ""
77 }
78 if strings.HasPrefix(uses, "./") {
79 return bad("local workflow references must be rooted in '.github/workflows'")
80 }
81 parts := strings.Split(uses, "@")
82 switch {
83 case len(parts) == 1 || (len(parts) == 2 && parts[1] == ""):
84 return bad("no version specified")
85 case len(parts) > 2:
86 return bad("too many '@' in workflow reference")
87 }
88 i := strings.Index(parts[0], ".github/workflows/")
89 if i < 0 {
90 return bad("references to workflows must be rooted in '.github/workflows'")
91 }
92 nwo := strings.FieldsFunc(parts[0][:i], func(r rune) bool { return r == '/' })
93 if len(nwo) != 2 {
94 return bad("references to workflows must be prefixed with format 'owner/repository/' or './' for local workflows")
95 }
96 if !validActionName(nwo[0]) || !validActionName(nwo[1]) {
97 return bad("owner name must be a valid repository owner name")
98 }
99 path := parts[0][i:]
100 for _, s := range strings.Split(path, "/") {
101 if s == "" || s == "." || s == ".." {
102 return bad("references to workflows must be rooted in '.github/workflows'")
103 }
104 }
105 return callRef{owner: nwo[0], repo: nwo[1], path: path, ref: parts[1]}, ""
106}
107
108// callFailure is a call that cannot be expanded; text is the job's error.
109type callFailure struct{ text string }
110
111func (e callFailure) Error() string { return e.text }
112
113func isCallFailure(err error) (string, bool) {
114 var cf callFailure
115 if errors.As(err, &cf) {
116 return cf.text, true
117 }
118 return "", false
119}
120
121// calledFile is a workflow file read for a call.
122type calledFile struct {
123 repository, path, ref, sha string
124 yaml []byte
125}
126
127// loadCallee finds the file a "uses:" names. home is the call whose file holds
128// the "uses:" (nil for the run's own workflow): a local reference is read in
129// the repository and at the commit of that file.
130func (c *CI) loadCallee(ctx context.Context, rs *runState, home *domain.CICall, rec domain.CIJob, ref callRef) (calledFile, error) {
131 var f calledFile
132 if ref.local {
133 f.path = ref.path
134 if home == nil {
135 f.repository, f.sha = rs.run.Repo.String(), rs.run.Sha
136 } else {
137 f.repository, f.sha = home.Repository, home.Sha
138 }
139 return f, c.readCallee(ctx, &f)
140 }
141 f.path, f.ref = ref.path, ref.ref
142 ar := actionRef{uses: ref.owner + "/" + ref.repo + "@" + ref.ref, owner: ref.owner, repo: ref.repo, ref: ref.ref}
143 by := c.onBehalfOf(*rs.src)
144 // One resolution per reference and attempt: what this planning resolved
145 // already (not stored yet), else what the attempt stored, else a fresh one.
146 var res domain.CIActionResolution
147 found := false
148 for _, r := range rs.resolutions {
149 if r.RunID == rec.RunID && r.RunAttempt == rec.RunAttempt && r.Key == ar.key() {
150 res, found = r, true
151 break
152 }
153 }
154 if !found {
155 stored, err := c.cfg.Store.GetActionResolution(ctx, rec.RunID, rec.RunAttempt, ar.key())
156 switch {
157 case err == nil:
158 res = stored
159 case errors.Is(err, domain.ErrNotFound):
160 var msg string
161 res, msg, err = c.resolveAction(ctx, rec, ar, newRemoteRefs(c.cfg.Remote, c.remoteSlots), by)
162 if err != nil {
163 return f, err
164 }
165 if msg != "" {
166 return f, callFailure{msg}
167 }
168 rs.resolutions = append(rs.resolutions, res)
169 default:
170 return f, err
171 }
172 }
173 // The source guard of ResolveActions (answer), whatever the resolution came
174 // from: a repository of this server that the actor cannot read is never
175 // served from anywhere else, and a stored ermite resolution of a repository
176 // that is gone has nothing to serve.
177 repo, onErmite, err := c.ermiteRepoOf(ctx, ar)
178 if err != nil {
179 return f, err
180 }
181 if onErmite {
182 if ok, err := c.pusherCanRead(ctx, by, rs.run.Repo, repo); err != nil {
183 return f, err
184 } else if !ok {
185 return f, callFailure{errUnresolvedRepo(ar)}
186 }
187 }
188 f.sha = res.ResolvedSHA
189 switch res.Source {
190 case domain.CIActionErmite:
191 if !onErmite {
192 return f, callFailure{errUnresolvedRepo(ar)}
193 }
194 f.repository = repo.Name.String()
195 default:
196 f.repository = ref.owner + "/" + ref.repo
197 }
198 return f, c.readCallee(ctx, &f)
199}
200
201// readCallee fills f.yaml from the repository of the server or github.com.
202func (c *CI) readCallee(ctx context.Context, f *calledFile) error {
203 notFound := callFailure{"Unable to find reusable workflow '" + f.path + "' in '" + f.repository + "' at '" + f.sha + "'"}
204 if owner, repo, ok := strings.Cut(f.repository, "/"); ok {
205 if c.cfg.Workflows == nil {
206 return fmt.Errorf("%w: this server cannot reach github.com", domain.ErrUnavailable)
207 }
208 data, err := c.cfg.Workflows.FetchWorkflow(ctx, owner, repo, f.sha, f.path, maxCIWorkflowBytes)
209 switch {
210 case errors.Is(err, domain.ErrNotFound):
211 return notFound
212 case errors.Is(err, domain.ErrResourceExhausted):
213 return callFailure{f.path + ": the workflow file is larger than 1 MiB"}
214 case err != nil:
215 return err
216 }
217 f.yaml = data
218 return nil
219 }
220 name, err := domain.ParseRepoName(f.repository)
221 if err != nil {
222 return notFound
223 }
224 id, err := domain.ParseObjectID(f.sha)
225 if err != nil {
226 return notFound
227 }
228 tp, err := domain.ParseTreePath(f.path)
229 if err != nil {
230 return notFound
231 }
232 _, rc, err := c.cfg.Reader.OpenBlob(ctx, name, domain.RevisionOf(id), tp)
233 if errors.Is(err, domain.ErrNotFound) {
234 return notFound
235 }
236 if err != nil {
237 return err
238 }
239 defer func() { _ = rc.Close() }()
240 data, err := io.ReadAll(io.LimitReader(rc, maxCIWorkflowBytes+1))
241 if err != nil {
242 return err
243 }
244 if len(data) > maxCIWorkflowBytes {
245 return callFailure{f.path + ": the workflow file is larger than 1 MiB"}
246 }
247 f.yaml = data
248 return nil
249}
250
251// callerPrefix is the text GitHub puts before an error of the called workflow
252// (TemplateStrings: CalledWorkflowNotValidWithErrors, LineColumn).
253func callerPrefix(wj *workflow.Job) string {
254 file, pos := "", template.Pos{}
255 if wj.Uses != nil {
256 file, pos = wj.Uses.File(), wj.Uses.Pos()
257 }
258 return fmt.Sprintf("In %s (Line: %d, Col: %d): Error from called workflow", file, pos.Line, pos.Col)
259}
260
261// calleeSpec is what ermite reads of "on.workflow_call" that the workflow model
262// does not keep: the outputs and the secrets (ermite-actions parses the inputs).
263type calleeSpec struct {
264 outputs map[string]string
265 secrets map[string]bool // name (upper case) to required
266}
267
268// readCalleeSpec reads on.workflow_call.outputs and .secrets from the YAML.
269func readCalleeSpec(src []byte) calleeSpec {
270 spec := calleeSpec{outputs: map[string]string{}, secrets: map[string]bool{}}
271 var doc yaml.Node
272 if yaml.Unmarshal(src, &doc) != nil || len(doc.Content) == 0 {
273 return spec
274 }
275 root := derefYAML(doc.Content[0])
276 for i := 0; root.Kind == yaml.MappingNode && i+1 < len(root.Content); i += 2 {
277 if root.Content[i].Value != "on" {
278 continue
279 }
280 on := derefYAML(root.Content[i+1])
281 for j := 0; on.Kind == yaml.MappingNode && j+1 < len(on.Content); j += 2 {
282 if on.Content[j].Value != "workflow_call" {
283 continue
284 }
285 wc := derefYAML(on.Content[j+1])
286 for k := 0; wc.Kind == yaml.MappingNode && k+1 < len(wc.Content); k += 2 {
287 body := derefYAML(wc.Content[k+1])
288 for m := 0; body.Kind == yaml.MappingNode && m+1 < len(body.Content); m += 2 {
289 name, def := body.Content[m].Value, derefYAML(body.Content[m+1])
290 switch wc.Content[k].Value {
291 case "outputs":
292 for q := 0; def.Kind == yaml.MappingNode && q+1 < len(def.Content); q += 2 {
293 if def.Content[q].Value == "value" {
294 spec.outputs[name] = derefYAML(def.Content[q+1]).Value
295 }
296 }
297 case "secrets":
298 req := false
299 for q := 0; def.Kind == yaml.MappingNode && q+1 < len(def.Content); q += 2 {
300 if def.Content[q].Value == "required" {
301 req = derefYAML(def.Content[q+1]).Value == "true"
302 }
303 }
304 spec.secrets[strings.ToUpper(name)] = req
305 }
306 }
307 }
308 }
309 }
310 return spec
311}
312
313func derefYAML(n *yaml.Node) *yaml.Node {
314 for range 16 {
315 if n.Kind != yaml.AliasNode || n.Alias == nil {
316 return n
317 }
318 n = n.Alias
319 }
320 return n
321}
322
323// callInputs checks the "with:" of a call against the inputs of the called
324// workflow and returns the inputs context: the value given, else the default,
325// else the zero of the type. Texts: WorkflowTemplateConverter.cs
326// (ConvertToWorkflowJobInputs, ValidateUndefinedParameters).
327func callInputs(ctx context.Context, defs []workflow.Input, given *expr.Dictionary, defEnv template.Env) (*expr.Dictionary, error) {
328 out := expr.NewDictionary()
329 byName := map[string]bool{}
330 for _, in := range defs {
331 byName[strings.ToLower(in.Name)] = true
332 var v expr.Value
333 have := false
334 if given != nil {
335 for _, p := range given.Pairs() {
336 if strings.EqualFold(p.Key, in.Name) {
337 cv, err := coerceInput(in, p.Value)
338 if err != nil {
339 return nil, err
340 }
341 v, have = cv, true
342 }
343 }
344 }
345 if !have && in.HasDefault {
346 dv, err := evalInterpolated(ctx, in.Default, defEnv)
347 if err != nil {
348 return nil, err
349 }
350 cv, err := coerceInput(in, dv)
351 if err != nil {
352 return nil, err
353 }
354 v, have = cv, true
355 }
356 if !have && in.Required {
357 return nil, callFailure{"Input " + in.Name + " is required, but not provided while calling."}
358 }
359 if !have {
360 switch in.Type {
361 case "boolean":
362 v = expr.Boolean(false)
363 case "number":
364 v = expr.Number(0)
365 default:
366 v = expr.String("")
367 }
368 }
369 out.Add(in.Name, v)
370 }
371 if given != nil {
372 for _, p := range given.Pairs() {
373 if !byName[strings.ToLower(p.Key)] {
374 return nil, callFailure{"Invalid input, " + p.Key + " is not defined in the referenced workflow."}
375 }
376 }
377 }
378 return out, nil
379}
380
381// coerceInput converts a value to the type of an input, as the template engine
382// does for a boolean, number or string (GetDefaultValueByType,
383// ConvertToInputValueDefinedType): a mismatch is "Unexpected value".
384func coerceInput(in workflow.Input, v expr.Value) (expr.Value, error) {
385 unexpected := func() (expr.Value, error) {
386 return nil, callFailure{"Unexpected value '" + expr.CoerceString(v) + "' for input '" + in.Name + "' of type '" + in.Type + "'"}
387 }
388 switch in.Type {
389 case "boolean":
390 switch x := v.(type) {
391 case expr.Boolean:
392 return x, nil
393 case expr.String:
394 switch strings.ToLower(string(x)) {
395 case "true":
396 return expr.Boolean(true), nil
397 case "false":
398 return expr.Boolean(false), nil
399 }
400 }
401 return unexpected()
402 case "number":
403 switch x := v.(type) {
404 case expr.Number:
405 return x, nil
406 case expr.String:
407 if f, err := strconv.ParseFloat(strings.TrimSpace(string(x)), 64); err == nil {
408 return expr.Number(f), nil
409 }
410 }
411 return unexpected()
412 }
413 switch v.(type) {
414 case expr.String, expr.Number, expr.Boolean:
415 if s, ok := v.(expr.String); ok {
416 return s, nil
417 }
418 return expr.String(expr.CoerceString(v)), nil
419 }
420 return unexpected()
421}
422
423// evalInterpolated evaluates a string with ${{ }} expressions (a default of an
424// input, the value of an output). A string that is one expression keeps the
425// type of its value.
426func evalInterpolated(ctx context.Context, s string, env template.Env) (expr.Value, error) {
427 if !strings.Contains(s, "${{") {
428 return expr.String(s), nil
429 }
430 ctxNames := make([]string, 0, len(env.Contexts))
431 for k := range env.Contexts {
432 ctxNames = append(ctxNames, k)
433 }
434 eval := func(src string) (expr.Value, error) {
435 node, err := expr.Parse(src, ctxNames, exprFuncs)
436 if err != nil {
437 return nil, err
438 }
439 return expr.Evaluate(ctx, node, expr.EvalContext{Contexts: env.Contexts})
440 }
441 exprs := expressionsIn(s)
442 if t := strings.TrimSpace(s); len(exprs) == 1 && strings.HasPrefix(t, "${{") && strings.HasSuffix(t, "}}") && strings.Count(t, "${{") == 1 {
443 return eval(exprs[0])
444 }
445 var b strings.Builder
446 rest := s
447 for _, src := range exprs {
448 i := strings.Index(rest, "${{")
449 b.WriteString(rest[:i])
450 v, err := eval(src)
451 if err != nil {
452 return nil, err
453 }
454 b.WriteString(expr.CoerceString(v))
455 rest = rest[i+3+len(src)+2:]
456 }
457 b.WriteString(rest)
458 return expr.String(b.String()), nil
459}
460
461// calleeOf parses the workflow a call record kept.
462func (rs *runState) calleeOf(call *domain.CIJob) (*workflow.Workflow, error) {
463 if call == nil || call.Call == nil {
464 return nil, fmt.Errorf("the call of this job is gone")
465 }
466 return rs.c.flows.parseCalled(call.Call.Path, call.Call.YAML)
467}
468
469// scopeOf finds the call record of a scoped job, nil for a job of the run's own
470// workflow.
471func (rs *runState) scopeOf(j domain.CIJob) *domain.CIJob {
472 if j.Scope == "" {
473 return nil
474 }
475 return rs.find(j.Scope)
476}
477
478// inputsOf is the "inputs" context of a job: its call's inputs, empty at the
479// top.
480func (rs *runState) inputsOf(j domain.CIJob) *expr.Dictionary {
481 if sc := rs.scopeOf(j); sc != nil && sc.Call != nil {
482 if v, err := expr.FromJSON([]byte(sc.Call.InputsJSON)); err == nil {
483 if d, ok := v.(*expr.Dictionary); ok {
484 return d
485 }
486 }
487 return expr.NewDictionary()
488 }
489 if j.Scope == "" && rs.run.Event == eventDispatch {
490 // The inputs of a workflow_dispatch run, typed by their declaration.
491 if wf, err := rs.c.flows.get(rs.run.ID, rs.run.WorkflowPath, rs.src.WorkflowYAML); err == nil {
492 return eventInputs(wf, rs.src.GithubJSON)
493 }
494 }
495 return expr.NewDictionary()
496}
497
498// instancesIn returns the indexes of the records of job id in a scope.
499func (rs *runState) instancesIn(scope, id string) []int {
500 var out []int
501 for i, j := range rs.jobs {
502 if j.Scope == scope && ((scope == "" && j.Key == id) || (scope != "" && j.CallID == id)) {
503 out = append(out, i)
504 }
505 }
506 return out
507}
508
509// rootKey is the key of the job of the run's own workflow a record descends
510// from.
511func rootKey(key string) string {
512 root, _, _ := strings.Cut(key, "/")
513 return root
514}
515
516// nextIndex is a position for a job added to the run.
517func (rs *runState) nextIndex() int {
518 n := 0
519 for _, j := range rs.jobs {
520 n = max(n, j.Index+1)
521 }
522 return n
523}
524
525// planCall expands a job that calls a reusable workflow. rec (at idx) becomes
526// the first call record, one per matrix leg; the jobs of the called workflow are
527// added as waiting records of the scope of each leg.
528func (rs *runState) planCall(ctx context.Context, wf *workflow.Workflow, wj *workflow.Job, idx int, env template.Env) error {
529 rec := rs.jobs[idx]
530 parent := rs.scopeOf(rec)
531 var home *domain.CICall
532 depth := 1
533 if parent != nil {
534 home, depth = parent.Call, parent.Call.Depth+1
535 }
536 if depth > maxReusableWorkflowDepth {
537 return callFailure{fmt.Sprintf("Nested reusable workflow depth exceeded %d.", maxReusableWorkflowDepth)}
538 }
539 ref, text := parseCallRef(wj.Uses.Value)
540 if text != "" {
541 return callFailure{text}
542 }
543 file, err := rs.c.loadCallee(ctx, rs, home, rec, ref)
544 if err != nil {
545 return err
546 }
547 prefix := callerPrefix(wj)
548 cw, errs := parseWorkflow(file.path, file.yaml)
549 if len(errs) > 0 {
550 return callFailure{prefix + " " + errs[0].Error()}
551 }
552 wc := cw.On.Event("workflow_call")
553 if wc == nil {
554 return callFailure{prefix + " workflow_call key is not defined in the referenced workflow."}
555 }
556 spec := readCalleeSpec(file.yaml)
557
558 eff, err := rs.callPermissions(wf, wj, rec, parent)
559 if err != nil {
560 return err
561 }
562 plan, err := wj.EvaluateStrategy(ctx, env)
563 if err != nil {
564 return err
565 }
566 vars, _ := env.Contexts["vars"].(*expr.Dictionary)
567 if vars == nil {
568 vars = expr.NewDictionary()
569 }
570 gh := env.Contexts["github"]
571
572 // The records of the run are bounded before anything changes: the calls, and
573 // the jobs of the called workflow for each (a job that is a matrix adds its
574 // legs when it is planned, checked there).
575 if err := rs.checkJobBudget(len(plan.Combos) - 1 + len(plan.Combos)*len(cw.Jobs)); err != nil {
576 return err
577 }
578 legs := make([]domain.CIJob, 0, len(plan.Combos))
579 for i := range plan.Combos {
580 menv, err := plan.MatrixEnv(env, i)
581 if err != nil {
582 return err
583 }
584 var given *expr.Dictionary
585 if wj.With != nil {
586 v, err := template.Evaluate(ctx, wj.With, menv)
587 if err != nil {
588 return err
589 }
590 given, _ = v.(*expr.Dictionary)
591 }
592 inEnv := template.Env{Contexts: map[string]expr.Value{"github": gh, "inputs": expr.NewDictionary(), "vars": vars}}
593 inputs, err := callInputs(ctx, wc.Inputs, given, inEnv)
594 if err != nil {
595 if text, ok := isCallFailure(err); ok {
596 return callFailure{prefix + " " + text}
597 }
598 return err
599 }
600 // The group of the calling job (job-level, evaluated like the group of
601 // any job) and the workflow-level group of the called workflow
602 // (evaluated with the caller's github context and the call's inputs).
603 // The call record is the entry of both.
604 var jobConc, wfConc domain.CIConcurrency
605 if wj.Concurrency != nil {
606 if jobConc, err = evalConcurrency(ctx, wj.Concurrency, menv); err != nil {
607 return err
608 }
609 jobConc.Evaluated = true
610 }
611 if cw.Concurrency != nil {
612 wenv := template.Env{Contexts: map[string]expr.Value{"github": gh, "inputs": inputs, "vars": vars}}
613 if wfConc, err = evalConcurrency(ctx, cw.Concurrency, wenv); err != nil {
614 return callFailure{prefix + " " + err.Error()}
615 }
616 wfConc.Evaluated = true
617 }
618 call := domain.CICall{
619 Uses: wj.Uses.Value, Path: file.path, Repository: file.repository, Ref: file.ref, Sha: file.sha, YAML: string(file.yaml),
620 InputsJSON: compactJSON(expr.ToJSON(inputs)), Depth: depth, Expanded: true, Concurrency: wfConc,
621 }
622 if err := rs.callSecrets(wj, menv, spec, &call); err != nil {
623 if text, ok := isCallFailure(err); ok {
624 return callFailure{prefix + " " + text}
625 }
626 return err
627 }
628 name, err := wj.DisplayName(ctx, menv, plan, i)
629 if err != nil {
630 return err
631 }
632 leg := rec
633 leg.Combo = i
634 leg.ID = domain.NewJobID(rs.run.ID, rec.Index, i)
635 if i > 0 {
636 leg.Version = 0
637 }
638 leg.Name = strings.TrimSpace(name)
639 if parent != nil {
640 leg.Name = parent.Name + " / " + leg.Name // "<caller> / <nested caller>"
641 }
642 leg.Call, leg.Planned, leg.Phase = &call, true, domain.CIPhaseWaiting
643 leg.Permissions = eff
644 leg.Concurrency = jobConc
645 leg.MatrixJSON = compactJSON(expr.ToJSON(plan.Combos[i].Values))
646 leg.ContextsJSON, leg.ComboTotal = "{}", len(plan.Combos)
647 leg.FailFast, leg.MaxParallel = plan.FailFast, plan.MaxParallel
648 leg.Labels, leg.TimeoutMinutes = nil, 0
649 legs = append(legs, leg)
650 }
651
652 // The new positions follow the highest one in use.
653 next := rs.nextIndex()
654 if next+len(legs)*len(cw.Jobs) > maxJobIndex+1 {
655 return callFailure{"The workflow has more jobs than the server tracks (" + strconv.Itoa(maxJobIndex+1) + ")."}
656 }
657 rs.jobs[idx] = legs[0]
658 rs.touch(&rs.jobs[idx])
659 for _, l := range legs[1:] {
660 rs.jobs = append(rs.jobs, l)
661 rs.dirty[l.ID] = true
662 }
663 next = rs.nextIndex()
664 now := rs.c.now()
665 for _, l := range legs {
666 for _, cj := range cw.Jobs {
667 needs := make([]string, len(cj.Needs))
668 for i, n := range cj.Needs {
669 needs[i] = l.Key + "/" + n
670 }
671 id := domain.NewJobID(rs.run.ID, next, 0)
672 rs.jobs = append(rs.jobs, domain.CIJob{
673 ID: id, RunID: rs.run.ID, Attempt: rs.attempt, RunAttempt: rs.attempt, Repo: rs.run.Repo, Key: l.Key + "/" + cj.ID,
674 Name: cj.ID, Phase: domain.CIPhaseWaiting, Needs: needs, Index: next, CreatedAt: now, Scope: l.ID, CallID: cj.ID,
675 })
676 rs.dirty[id] = true
677 next++
678 }
679 }
680 rs.sortJobs()
681 return nil
682}
683
684// callPermissions is the permissions a call grants to the jobs of its scope: the
685// "permissions:" of the calling job, else of its workflow, else those the
686// containing call allows. A reusable workflow can only keep or lower them: a
687// request above the allowed is the error of PermissionsHelper.cs.
688func (rs *runState) callPermissions(wf *workflow.Workflow, wj *workflow.Job, rec domain.CIJob, parent *domain.CIJob) (map[string]string, error) {
689 got := domain.ResolvePermissions(permissionSpec(wf.Permissions), permissionSpec(wj.Permissions))
690 if parent == nil {
691 return got, nil
692 }
693 allowed := domain.TokenPermissions(parent.Permissions, true)
694 if got == nil {
695 return allowed, nil
696 }
697 if asked, ok := domain.PermissionViolations(got, allowed); len(asked) > 0 {
698 return nil, callFailure{fmt.Sprintf("Error calling workflow '%s'. The nested job '%s' is requesting '%s', but is only allowed '%s'.",
699 parent.Call.Uses, wj.ID, strings.Join(asked, ", "), strings.Join(ok, ", "))}
700 }
701 return got, nil
702}
703
704// scopedPermissions are the permissions of a job of a called workflow: its own
705// "permissions:", else its workflow's, else what the call allows; asking for
706// more than the call allows is an error.
707func (rs *runState) scopedPermissions(wf *workflow.Workflow, wj *workflow.Job, call *domain.CIJob) (map[string]string, error) {
708 allowed := domain.TokenPermissions(call.Permissions, true)
709 got := domain.ResolvePermissions(permissionSpec(wf.Permissions), permissionSpec(wj.Permissions))
710 if got == nil {
711 return call.Permissions, nil
712 }
713 if asked, ok := domain.PermissionViolations(got, allowed); len(asked) > 0 {
714 what := "The nested job '" + wj.ID + "'"
715 if wj.Permissions == nil {
716 what = "The workflow"
717 }
718 return nil, callFailure{fmt.Sprintf("Error calling workflow '%s'. %s is requesting '%s', but is only allowed '%s'.",
719 call.Call.Uses, what, strings.Join(asked, ", "), strings.Join(ok, ", "))}
720 }
721 return got, nil
722}
723
724// callSecrets reads the "secrets:" of a call: "inherit", or a mapping. Planning
725// validates names only (the expressions were checked against the contexts the
726// schema allows when the file was parsed) and keeps no expression and no value:
727// the mapping is read from the pinned workflow file, as a template token, when
728// a job is assigned (callMapping, mapSecrets), and evaluated once there. Only
729// the plain contexts the expressions read (needs, matrix, strategy, inputs,
730// vars: none is secret) are kept, in call.SecretCtx, as they are now.
731func (rs *runState) callSecrets(wj *workflow.Job, env template.Env, spec calleeSpec, call *domain.CICall) error {
732 if wj.InheritSecrets {
733 call.Inherit = true
734 return nil
735 }
736 if wj.Secrets == nil {
737 return rs.checkRequiredSecrets(spec, map[string]bool{})
738 }
739 call.Secrets = map[string]string{}
740 given := map[string]bool{}
741 for _, p := range wj.Secrets.Pairs {
742 name := strings.ToUpper(p.Key.String())
743 if _, ok := spec.secrets[name]; !ok {
744 return callFailure{"Invalid secret, " + p.Key.String() + " is not defined in the referenced workflow."}
745 }
746 given[name] = true
747 call.Secrets[name] = "" // the name only
748 }
749 plain := map[string]json.RawMessage{}
750 for _, k := range []string{"needs", "matrix", "strategy", "inputs", "vars"} {
751 if v, ok := env.Contexts[k]; ok && v != nil {
752 plain[k] = json.RawMessage(compactJSON(expr.ToJSON(v)))
753 }
754 }
755 b, err := json.Marshal(plain)
756 if err != nil {
757 return err
758 }
759 call.SecretCtx = string(b)
760 return rs.checkRequiredSecrets(spec, given)
761}
762
763// callMapping returns the "secrets:" mapping of a call as the template tokens of
764// the workflow file that holds the calling job: the run's own workflow, or the
765// called workflow of the call that contains it. The tokens come from the file
766// itself and are never rebuilt from text; names are upper-cased.
767func (rs *runState) callMapping(cr *domain.CIJob) (map[string]template.Token, error) {
768 path, yamlText, id := rs.run.WorkflowPath, rs.src.WorkflowYAML, cr.Key
769 if p := rs.scopeOf(*cr); p != nil && p.Call != nil {
770 path, yamlText, id = p.Call.Path, p.Call.YAML, cr.CallID
771 }
772 wf, err := rs.c.flows.parseCalled(path, yamlText)
773 if err != nil {
774 return nil, err
775 }
776 wj := wf.Job(id)
777 if wj == nil {
778 return nil, fmt.Errorf("the calling job is gone")
779 }
780 out := map[string]template.Token{}
781 if wj.Secrets != nil {
782 for _, p := range wj.Secrets.Pairs {
783 out[strings.ToUpper(p.Key.String())] = p.Value
784 }
785 }
786 return out, nil
787}
788
789// secretsReadBy returns the secrets a template token refers to: the expressions
790// of the token are parsed, not the text they produce.
791func secretsReadBy(t template.Token) secretUse {
792 w := &secretWalk{names: map[string]bool{}}
793 template.Walk(t, func(x template.Token) bool {
794 if be, ok := x.(*template.BasicExpression); ok {
795 w.expression(be.Expression)
796 }
797 return true
798 })
799 use := secretUse{All: w.all || w.tooDeep}
800 for n := range w.names {
801 use.Names = append(use.Names, n)
802 }
803 slices.Sort(use.Names)
804 return use
805}
806
807func (rs *runState) checkRequiredSecrets(spec calleeSpec, given map[string]bool) error {
808 for _, name := range sortedKeys(spec.secrets) {
809 if spec.secrets[name] && !given[name] {
810 return callFailure{"Secret " + name + " is required, but not provided while calling."}
811 }
812 }
813 return nil
814}
815
816// chainOf returns the call records a job runs under, innermost first.
817func (rs *runState) chainOf(j domain.CIJob) []*domain.CIJob {
818 var out []*domain.CIJob
819 for sc := rs.scopeOf(j); sc != nil && len(out) <= maxReusableWorkflowDepth; sc = rs.scopeOf(*sc) {
820 out = append(out, sc)
821 }
822 return out
823}
824
825// scopedSecretUse is the secrets of the run's own workflow that a job of a
826// called workflow reads, through the "secrets:" of each call that leads to it.
827// inner is the use inside the called workflow of the job.
828func (rs *runState) scopedSecretUse(j domain.CIJob) (top, inner secretUse) {
829 chain := rs.chainOf(j)
830 inner = secretsUsedBy(chain[0].Call.YAML, j.CallID)
831 cur := inner
832 for _, c := range chain {
833 cur = rs.translateUse(cur, c)
834 }
835 return cur, inner
836}
837
838// translateUse turns the secrets a called workflow reads into the secrets its
839// caller must hold: with "inherit" the same ones, else the secrets that the
840// expressions of the call's mapping, for the ones it reads, refer to (all of
841// them when an expression is not a plain name). A mapping that cannot be read
842// asks for all of them: the safe side for the job, which then gets none it
843// cannot evaluate.
844func (rs *runState) translateUse(use secretUse, cr *domain.CIJob) secretUse {
845 if cr.Call.Inherit {
846 return use
847 }
848 mapping, err := rs.callMapping(cr)
849 if err != nil {
850 return secretUse{All: true}
851 }
852 var out secretUse
853 add := func(t template.Token) {
854 u := secretsReadBy(t)
855 out.All = out.All || u.All
856 for _, n := range u.Names {
857 if !slices.Contains(out.Names, n) {
858 out.Names = append(out.Names, n)
859 }
860 }
861 }
862 for name, t := range mapping {
863 if use.All || slices.Contains(use.Names, name) {
864 add(t)
865 }
866 }
867 slices.Sort(out.Names)
868 return out
869}
870
871// mapSecrets gives a job of a called workflow the secrets it reads, named as the
872// called workflow names them. top holds the secrets of the run's own workflow
873// that it may use (empty for a run that withholds them: the caller decides who
874// gets any). From the outermost call, each mapping value is the template token
875// of the workflow file, evaluated once with the secrets of its calling scope and
876// the plain contexts the call kept; the result is a final string, never parsed
877// again, and is the secrets of the scope below. A secret not passed (not in
878// "secrets:" and no "inherit") is not there. A value that does not evaluate is
879// empty, and nothing of the error is logged: it may hold the secret.
880func (rs *runState) mapSecrets(ctx context.Context, j domain.CIJob, top map[string]string, inner secretUse) map[string]string {
881 chain := rs.chainOf(j)
882 vals := top
883 gh, ghErr := rs.githubValue("", rs.run.Attempt)
884 for i := len(chain) - 1; i >= 0; i-- {
885 cr := chain[i]
886 if cr.Call.Inherit {
887 continue
888 }
889 mapping, merr := rs.callMapping(cr)
890 sd := expr.NewDictionary()
891 for _, k := range sortedKeys(vals) {
892 sd.Add(k, expr.String(vals[k]))
893 }
894 env := template.Env{Contexts: map[string]expr.Value{"secrets": sd}}
895 if ghErr == nil {
896 env.Contexts["github"] = gh
897 }
898 var plain map[string]json.RawMessage
899 _ = json.Unmarshal([]byte(cr.Call.SecretCtx), &plain)
900 for k, raw := range plain {
901 if v, err := expr.FromJSON(raw); err == nil {
902 env.Contexts[k] = v
903 }
904 }
905 next := map[string]string{}
906 for name := range cr.Call.Secrets {
907 t, ok := mapping[name]
908 if merr != nil || !ok {
909 next[name] = ""
910 continue
911 }
912 v, err := template.EvaluateString(ctx, t, env)
913 if err != nil {
914 // Never the error: a format() error holds its arguments.
915 ctxLog(ctx).Warn().Str("secret", name).Msg("ci: a secret passed to a called workflow could not evaluate: it is empty")
916 v = ""
917 }
918 next[name] = v
919 }
920 vals = next
921 }
922 out := map[string]string{}
923 for n, v := range vals {
924 if inner.All || slices.Contains(inner.Names, n) {
925 out[n] = v
926 }
927 }
928 return out
929}
930
931// callHeld reports whether a call holds the concurrency groups it asked for: the
932// group of the calling job and the workflow-level group of the called workflow.
933// The jobs of a call are not planned before.
934func callHeld(call *domain.CIJob) bool {
935 if call.Call == nil || !call.Call.Admitted {
936 return false
937 }
938 if call.Concurrency.Group != "" && !call.Concurrency.FromRun && call.Concurrency.State != domain.CIConcurrencyHolding {
939 return false
940 }
941 return call.Call == nil || call.Call.Concurrency.Group == "" || call.Call.Concurrency.State == domain.CIConcurrencyHolding
942}
943
944// admitCalls lets the calls of a matrix start while the max-parallel of the
945// calling job's strategy allows it, in the order of the legs, as promote does
946// for the jobs that run. A call that is admitted asks for its groups and has its
947// jobs planned; one that is not waits, and starts nothing.
948func (rs *runState) admitCalls() bool {
949 running := map[string]int{}
950 for _, j := range rs.jobs {
951 if j.Call != nil && j.Call.Admitted && !j.Ended() {
952 running[j.Scope+"\x00"+j.Key]++
953 }
954 }
955 changed := false
956 for i := range rs.jobs {
957 j := &rs.jobs[i]
958 if j.Call == nil || j.Call.Admitted || j.Ended() || j.Phase != domain.CIPhaseWaiting {
959 continue
960 }
961 k := j.Scope + "\x00" + j.Key
962 if j.MaxParallel > 0 && running[k] >= j.MaxParallel {
963 continue
964 }
965 call := *j.Call
966 call.Admitted = true
967 j.Call, changed = &call, true
968 running[k]++
969 rs.touch(j)
970 }
971 return changed
972}
973
974// checkJobBudget refuses an expansion that would give the run more job records
975// than a commit may have checks (MaxCIChecks: each record has one check, and the
976// merge rule cannot read more). extra is the number of records the expansion
977// adds (the record of the job being expanded becomes the first of its legs).
978// GitHub's text is the runner's (JobCountValidator.cs); its limit applies to the
979// jobs across all referenced files, and the matrix of one job is capped at 256
980// by ermite-actions (ErrTooManyConfigurations).
981func (rs *runState) checkJobBudget(extra int) error {
982 if len(rs.jobs)+extra > MaxCIChecks {
983 return callFailure{fmt.Sprintf("Workflows may not contain more than %d jobs across all referenced files", MaxCIChecks)}
984 }
985 return nil
986}
987
988// holdCalls asks for the turn of the calls that wait for a group: the group of
989// the calling job first, then the one of the called workflow, so that a call
990// waits in one group at a time. The call record is the entry, and holds both
991// until it ends (releaseEnded).
992func (rs *runState) holdCalls(ctx context.Context) (bool, error) {
993 changed := false
994 for i := range rs.jobs {
995 j := &rs.jobs[i]
996 if j.Call == nil || !j.Call.Expanded || !j.Call.Admitted || j.Ended() || j.Phase != domain.CIPhaseWaiting {
997 continue
998 }
999 entry := domain.CIConcurrencyEntry{RunID: j.RunID, Attempt: j.RunAttempt, JobID: j.ID}
1000 if g := j.Concurrency; g.Group != "" && !g.FromRun && g.State != domain.CIConcurrencyHolding {
1001 e := entry
1002 e.Cancel, e.Max = g.Cancel, g.Max
1003 st, err := rs.acquire(ctx, g.Group, e)
1004 if err != nil {
1005 return changed, err
1006 }
1007 if st != g.State {
1008 j.Concurrency.State, changed = st, true
1009 rs.touch(j)
1010 }
1011 if st != domain.CIConcurrencyHolding {
1012 continue
1013 }
1014 }
1015 if g := j.Call.Concurrency; g.Group != "" && g.State != domain.CIConcurrencyHolding {
1016 e := entry
1017 e.Cancel, e.Max = g.Cancel, g.Max
1018 st, err := rs.acquire(ctx, g.Group, e)
1019 if err != nil {
1020 return changed, err
1021 }
1022 if st != g.State {
1023 call := *j.Call
1024 call.Concurrency.State = st
1025 j.Call, changed = &call, true
1026 rs.touch(j)
1027 }
1028 }
1029 }
1030 return changed, nil
1031}
1032
1033// advanceCalls plans the jobs of called workflows whose needs ended and ends
1034// the call records whose jobs have. It returns at the first change, since a
1035// change reorders the records.
1036func (rs *runState) advanceCalls(ctx context.Context) (bool, error) {
1037 if rs.admitCalls() {
1038 return true, nil
1039 }
1040 if changed, err := rs.holdCalls(ctx); err != nil || changed {
1041 return changed, err
1042 }
1043 for i := range rs.jobs {
1044 rec := rs.jobs[i]
1045 if rec.Scope == "" || rec.Planned || rec.Ended() || rec.Phase != domain.CIPhaseWaiting {
1046 continue
1047 }
1048 call := rs.scopeOf(rec)
1049 if call == nil {
1050 rs.end(&rs.jobs[i], domain.CIFailure, "the call of this job is gone")
1051 return true, nil
1052 }
1053 if call.Ended() {
1054 rs.end(&rs.jobs[i], domain.CICancelled, "")
1055 return true, nil
1056 }
1057 if call.Call == nil || !call.Call.Admitted {
1058 continue // the max-parallel of the calling job holds the call back
1059 }
1060 if !callHeld(call) {
1061 // Waits for the group of its call: shown as pending, as the jobs
1062 // of a run behind a workflow-level group.
1063 if rs.run.Concurrency.Group == "" && rec.Concurrency.State != domain.CIConcurrencyPending {
1064 g := call.Concurrency
1065 if g.Group == "" || g.State == domain.CIConcurrencyHolding {
1066 g = call.Call.Concurrency
1067 }
1068 rs.jobs[i].Concurrency = domain.CIConcurrency{Group: g.Group, State: domain.CIConcurrencyPending, FromRun: true, Evaluated: true}
1069 rs.touch(&rs.jobs[i])
1070 return true, nil
1071 }
1072 continue
1073 }
1074 if rec.Concurrency.FromRun && rs.run.Concurrency.Group == "" {
1075 rs.jobs[i].Concurrency = domain.CIConcurrency{}
1076 rs.touch(&rs.jobs[i])
1077 }
1078 cw, err := rs.calleeOf(call)
1079 if err != nil {
1080 rs.end(&rs.jobs[i], domain.CIFailure, err.Error())
1081 return true, nil
1082 }
1083 cj := cw.Job(rec.CallID)
1084 if cj == nil {
1085 rs.end(&rs.jobs[i], domain.CIFailure, "the called workflow has no job '"+rec.CallID+"'")
1086 return true, nil
1087 }
1088 if len(rs.instancesIn(rec.Scope, rec.CallID)) != 1 {
1089 continue
1090 }
1091 needs, st, ready := rs.needsOf(cw, cj, rec.Scope)
1092 if !ready {
1093 continue
1094 }
1095 rs.planJob(ctx, cw, cj, i, needs, st)
1096 return true, nil
1097 }
1098 for i := range rs.jobs {
1099 call := &rs.jobs[i]
1100 if call.Call == nil || !call.Call.Expanded {
1101 continue
1102 }
1103 var scoped []int
1104 allEnded := true
1105 for k, j := range rs.jobs {
1106 if j.Scope == call.ID {
1107 scoped = append(scoped, k)
1108 allEnded = allEnded && j.Ended()
1109 }
1110 }
1111 if call.Ended() {
1112 changed := false
1113 for _, k := range scoped {
1114 s := &rs.jobs[k]
1115 switch {
1116 case s.Ended():
1117 case s.Active():
1118 rs.requestCancel(s, domain.CICancelUser, endedCallMessage(*call))
1119 default:
1120 rs.end(s, domain.CICancelled, "")
1121 changed = true
1122 }
1123 }
1124 if changed {
1125 return true, nil
1126 }
1127 continue
1128 }
1129 if allEnded {
1130 rs.finishCall(ctx, i, scoped)
1131 return true, nil
1132 }
1133 }
1134 return false, nil
1135}
1136
1137// finishCall ends a call record with the conclusion of its jobs and the outputs
1138// of the called workflow.
1139func (rs *runState) finishCall(ctx context.Context, idx int, scoped []int) {
1140 call := &rs.jobs[idx]
1141 var top []domain.CIJob
1142 for _, k := range scoped {
1143 top = append(top, rs.jobs[k])
1144 }
1145 conc, msg := domain.CISuccess, ""
1146 switch result(top) {
1147 case "failure":
1148 conc = domain.CIFailure
1149 msg = "A job of the called workflow failed"
1150 for _, j := range top {
1151 if j.Conclusion.IsFailure() && !j.ContinueOnError {
1152 msg = "The job '" + j.Name + "' of the called workflow failed"
1153 break
1154 }
1155 }
1156 case "cancelled":
1157 conc = domain.CICancelled
1158 case "skipped":
1159 conc = domain.CISkipped
1160 }
1161 jobs := expr.NewDictionary()
1162 for _, id := range distinctCallIDs(top) {
1163 var insts []domain.CIJob
1164 for _, j := range top {
1165 if j.CallID == id {
1166 insts = append(insts, j)
1167 }
1168 }
1169 outs := expr.NewDictionary()
1170 for _, in := range insts {
1171 for _, k := range sortedKeys(in.Outputs) {
1172 if v := in.Outputs[k]; v != "" || !hasKey(outs, k) {
1173 setKey(outs, k, expr.String(v))
1174 }
1175 }
1176 }
1177 d := expr.NewDictionary()
1178 d.Add("result", expr.String(result(insts)))
1179 d.Add("outputs", outs)
1180 jobs.Add(id, d)
1181 }
1182 outputs := map[string]string{}
1183 spec := readCalleeSpec([]byte(call.Call.YAML))
1184 if len(spec.outputs) > 0 {
1185 gh, err := rs.githubValue(call.Key, rs.run.Attempt)
1186 vars, verr := rs.c.varsContext(ctx, rs.run.Repo)
1187 if err == nil && verr == nil {
1188 env := template.Env{Contexts: map[string]expr.Value{"github": gh, "inputs": expr.NewDictionary(), "vars": vars, "jobs": jobs}}
1189 // The inputs of the call itself.
1190 if v, err := expr.FromJSON([]byte(call.Call.InputsJSON)); err == nil {
1191 env.Contexts["inputs"] = v
1192 }
1193 for _, name := range sortedKeys(spec.outputs) {
1194 v, err := evalInterpolated(ctx, spec.outputs[name], env)
1195 if err != nil {
1196 ctxLog(ctx).Warn().Str("output", name).Msg("ci: an output of a called workflow could not evaluate")
1197 continue
1198 }
1199 outputs[name] = expr.CoerceString(v)
1200 }
1201 }
1202 }
1203 call.Outputs = outputs
1204 rs.end(call, conc, msg)
1205}
1206
1207func distinctCallIDs(js []domain.CIJob) []string {
1208 var out []string
1209 for _, j := range js {
1210 if !slices.Contains(out, j.CallID) {
1211 out = append(out, j.CallID)
1212 }
1213 }
1214 return out
1215}
1216
1217// parseCalled parses a called workflow, once per distinct file.
1218func (w *wfCache) parseCalled(path, yamlText string) (*workflow.Workflow, error) {
1219 sum := sha256.Sum256([]byte(path + "\x00" + yamlText))
1220 key := hex.EncodeToString(sum[:])
1221 w.mu.Lock()
1222 if wf, ok := w.called[key]; ok {
1223 w.mu.Unlock()
1224 return wf, nil
1225 }
1226 w.mu.Unlock()
1227 wf, errs := workflow.Parse(path, []byte(yamlText))
1228 if len(errs) > 0 {
1229 return nil, fmt.Errorf("the called workflow no longer parses: %s", errs[0].Error())
1230 }
1231 w.mu.Lock()
1232 defer w.mu.Unlock()
1233 if len(w.called) >= w.max {
1234 clear(w.called)
1235 }
1236 w.called[key] = wf
1237 return wf, nil
1238}
1239
1240// jobKeysOf are the keys of the "job" context the server knows. A job of a
1241// called workflow names the called workflow in them (workflow_ref,
1242// workflow_sha, workflow_repository, workflow_file_path); github.workflow_ref
1243// and github.workflow_sha stay the caller's (the github context is the
1244// caller's).
1245func (rs *runState) jobKeysOf(ctx context.Context, base domain.CIJob, combo int) ordered {
1246 keys := rs.c.jobKeys(ctx, rs.run, base.Index, combo)
1247 call := rs.scopeOf(base)
1248 if call == nil || call.Call == nil {
1249 return keys
1250 }
1251 c := call.Call
1252 full := c.Repository
1253 if !strings.Contains(full, "/") {
1254 full = rs.c.cfg.Owner + "/" + full
1255 }
1256 ref := c.Ref
1257 if ref == "" {
1258 ref = rs.run.Ref
1259 }
1260 return ordered{
1261 keys[0], {"workflow_ref", full + "/" + c.Path + "@" + ref}, {"workflow_sha", c.Sha},
1262 {"workflow_repository", full}, {"workflow_file_path", c.Path},
1263 }
1264}
1265
1266// endedCallMessage is what the jobs of a call that ended are told: the reason
1267// the call was cancelled for (a newer call of its concurrency group), else that
1268// the calling job ended.
1269func endedCallMessage(call domain.CIJob) string {
1270 if call.Concurrency.Preempted || call.Error != "" {
1271 return call.Error
1272 }
1273 return "Canceled because the calling job ended"
1274}
1275
1276// callResolutions copies the resolutions of the reusable workflows that the
1277// attempt of rs called to the attempt next.
1278func (c *CI) callResolutions(ctx context.Context, rs *runState, next int32) ([]domain.CIActionResolution, error) {
1279 var out []domain.CIActionResolution
1280 seen := map[string]bool{}
1281 for _, j := range rs.jobs {
1282 if j.Call == nil {
1283 continue
1284 }
1285 ref, text := parseCallRef(j.Call.Uses)
1286 if text != "" || ref.local {
1287 continue
1288 }
1289 key := actionRef{owner: ref.owner, repo: ref.repo, ref: ref.ref}.key()
1290 if seen[key] {
1291 continue
1292 }
1293 seen[key] = true
1294 res, err := c.cfg.Store.GetActionResolution(ctx, rs.run.ID, rs.attempt, key)
1295 if errors.Is(err, domain.ErrNotFound) {
1296 continue
1297 }
1298 if err != nil {
1299 return nil, err
1300 }
1301 res.RunAttempt = next
1302 out = append(out, res)
1303 }
1304 return out, nil
1305}