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 }
488 return expr.NewDictionary()
489}
490
491// instancesIn returns the indexes of the records of job id in a scope.
492func (rs *runState) instancesIn(scope, id string) []int {
493 var out []int
494 for i, j := range rs.jobs {
495 if j.Scope == scope && ((scope == "" && j.Key == id) || (scope != "" && j.CallID == id)) {
496 out = append(out, i)
497 }
498 }
499 return out
500}
501
502// rootKey is the key of the job of the run's own workflow a record descends
503// from.
504func rootKey(key string) string {
505 root, _, _ := strings.Cut(key, "/")
506 return root
507}
508
509// nextIndex is a position for a job added to the run.
510func (rs *runState) nextIndex() int {
511 n := 0
512 for _, j := range rs.jobs {
513 n = max(n, j.Index+1)
514 }
515 return n
516}
517
518// planCall expands a job that calls a reusable workflow. rec (at idx) becomes
519// the first call record, one per matrix leg; the jobs of the called workflow are
520// added as waiting records of the scope of each leg.
521func (rs *runState) planCall(ctx context.Context, wf *workflow.Workflow, wj *workflow.Job, idx int, env template.Env) error {
522 rec := rs.jobs[idx]
523 parent := rs.scopeOf(rec)
524 var home *domain.CICall
525 depth := 1
526 if parent != nil {
527 home, depth = parent.Call, parent.Call.Depth+1
528 }
529 if depth > maxReusableWorkflowDepth {
530 return callFailure{fmt.Sprintf("Nested reusable workflow depth exceeded %d.", maxReusableWorkflowDepth)}
531 }
532 ref, text := parseCallRef(wj.Uses.Value)
533 if text != "" {
534 return callFailure{text}
535 }
536 file, err := rs.c.loadCallee(ctx, rs, home, rec, ref)
537 if err != nil {
538 return err
539 }
540 prefix := callerPrefix(wj)
541 cw, errs := workflow.Parse(file.path, file.yaml)
542 if len(errs) > 0 {
543 return callFailure{prefix + " " + errs[0].Error()}
544 }
545 wc := cw.On.Event("workflow_call")
546 if wc == nil {
547 return callFailure{prefix + " workflow_call key is not defined in the referenced workflow."}
548 }
549 spec := readCalleeSpec(file.yaml)
550
551 eff, err := rs.callPermissions(wf, wj, rec, parent)
552 if err != nil {
553 return err
554 }
555 plan, err := wj.EvaluateStrategy(ctx, env)
556 if err != nil {
557 return err
558 }
559 vars, _ := env.Contexts["vars"].(*expr.Dictionary)
560 if vars == nil {
561 vars = expr.NewDictionary()
562 }
563 gh := env.Contexts["github"]
564
565 // The records of the run are bounded before anything changes: the calls, and
566 // the jobs of the called workflow for each (a job that is a matrix adds its
567 // legs when it is planned, checked there).
568 if err := rs.checkJobBudget(len(plan.Combos) - 1 + len(plan.Combos)*len(cw.Jobs)); err != nil {
569 return err
570 }
571 legs := make([]domain.CIJob, 0, len(plan.Combos))
572 for i := range plan.Combos {
573 menv, err := plan.MatrixEnv(env, i)
574 if err != nil {
575 return err
576 }
577 var given *expr.Dictionary
578 if wj.With != nil {
579 v, err := template.Evaluate(ctx, wj.With, menv)
580 if err != nil {
581 return err
582 }
583 given, _ = v.(*expr.Dictionary)
584 }
585 inEnv := template.Env{Contexts: map[string]expr.Value{"github": gh, "inputs": expr.NewDictionary(), "vars": vars}}
586 inputs, err := callInputs(ctx, wc.Inputs, given, inEnv)
587 if err != nil {
588 if text, ok := isCallFailure(err); ok {
589 return callFailure{prefix + " " + text}
590 }
591 return err
592 }
593 // The group of the calling job (job-level, evaluated like the group of
594 // any job) and the workflow-level group of the called workflow
595 // (evaluated with the caller's github context and the call's inputs).
596 // The call record is the entry of both.
597 var jobConc, wfConc domain.CIConcurrency
598 if wj.Concurrency != nil {
599 if jobConc, err = evalConcurrency(ctx, wj.Concurrency, menv); err != nil {
600 return err
601 }
602 jobConc.Evaluated = true
603 }
604 if cw.Concurrency != nil {
605 wenv := template.Env{Contexts: map[string]expr.Value{"github": gh, "inputs": inputs, "vars": vars}}
606 if wfConc, err = evalConcurrency(ctx, cw.Concurrency, wenv); err != nil {
607 return callFailure{prefix + " " + err.Error()}
608 }
609 wfConc.Evaluated = true
610 }
611 call := domain.CICall{
612 Uses: wj.Uses.Value, Path: file.path, Repository: file.repository, Ref: file.ref, Sha: file.sha, YAML: string(file.yaml),
613 InputsJSON: compactJSON(expr.ToJSON(inputs)), Depth: depth, Expanded: true, Concurrency: wfConc,
614 }
615 if err := rs.callSecrets(wj, menv, spec, &call); err != nil {
616 if text, ok := isCallFailure(err); ok {
617 return callFailure{prefix + " " + text}
618 }
619 return err
620 }
621 name, err := wj.DisplayName(ctx, menv, plan, i)
622 if err != nil {
623 return err
624 }
625 leg := rec
626 leg.Combo = i
627 leg.ID = domain.NewJobID(rs.run.ID, rec.Index, i)
628 if i > 0 {
629 leg.Version = 0
630 }
631 leg.Name = strings.TrimSpace(name)
632 if parent != nil {
633 leg.Name = parent.Name + " / " + leg.Name // "<caller> / <nested caller>"
634 }
635 leg.Call, leg.Planned, leg.Phase = &call, true, domain.CIPhaseWaiting
636 leg.Permissions = eff
637 leg.Concurrency = jobConc
638 leg.MatrixJSON = compactJSON(expr.ToJSON(plan.Combos[i].Values))
639 leg.ContextsJSON, leg.ComboTotal = "{}", len(plan.Combos)
640 leg.FailFast, leg.MaxParallel = plan.FailFast, plan.MaxParallel
641 leg.Labels, leg.TimeoutMinutes = nil, 0
642 legs = append(legs, leg)
643 }
644
645 // The new positions follow the highest one in use.
646 next := rs.nextIndex()
647 if next+len(legs)*len(cw.Jobs) > maxJobIndex+1 {
648 return callFailure{"The workflow has more jobs than the server tracks (" + strconv.Itoa(maxJobIndex+1) + ")."}
649 }
650 rs.jobs[idx] = legs[0]
651 rs.touch(&rs.jobs[idx])
652 for _, l := range legs[1:] {
653 rs.jobs = append(rs.jobs, l)
654 rs.dirty[l.ID] = true
655 }
656 next = rs.nextIndex()
657 now := rs.c.now()
658 for _, l := range legs {
659 for _, cj := range cw.Jobs {
660 needs := make([]string, len(cj.Needs))
661 for i, n := range cj.Needs {
662 needs[i] = l.Key + "/" + n
663 }
664 id := domain.NewJobID(rs.run.ID, next, 0)
665 rs.jobs = append(rs.jobs, domain.CIJob{
666 ID: id, RunID: rs.run.ID, Attempt: rs.attempt, RunAttempt: rs.attempt, Repo: rs.run.Repo, Key: l.Key + "/" + cj.ID,
667 Name: cj.ID, Phase: domain.CIPhaseWaiting, Needs: needs, Index: next, CreatedAt: now, Scope: l.ID, CallID: cj.ID,
668 })
669 rs.dirty[id] = true
670 next++
671 }
672 }
673 rs.sortJobs()
674 return nil
675}
676
677// callPermissions is the permissions a call grants to the jobs of its scope: the
678// "permissions:" of the calling job, else of its workflow, else those the
679// containing call allows. A reusable workflow can only keep or lower them: a
680// request above the allowed is the error of PermissionsHelper.cs.
681func (rs *runState) callPermissions(wf *workflow.Workflow, wj *workflow.Job, rec domain.CIJob, parent *domain.CIJob) (map[string]string, error) {
682 got := domain.ResolvePermissions(permissionSpec(wf.Permissions), permissionSpec(wj.Permissions))
683 if parent == nil {
684 return got, nil
685 }
686 allowed := domain.TokenPermissions(parent.Permissions, true)
687 if got == nil {
688 return allowed, nil
689 }
690 if asked, ok := domain.PermissionViolations(got, allowed); len(asked) > 0 {
691 return nil, callFailure{fmt.Sprintf("Error calling workflow '%s'. The nested job '%s' is requesting '%s', but is only allowed '%s'.",
692 parent.Call.Uses, wj.ID, strings.Join(asked, ", "), strings.Join(ok, ", "))}
693 }
694 return got, nil
695}
696
697// scopedPermissions are the permissions of a job of a called workflow: its own
698// "permissions:", else its workflow's, else what the call allows; asking for
699// more than the call allows is an error.
700func (rs *runState) scopedPermissions(wf *workflow.Workflow, wj *workflow.Job, call *domain.CIJob) (map[string]string, error) {
701 allowed := domain.TokenPermissions(call.Permissions, true)
702 got := domain.ResolvePermissions(permissionSpec(wf.Permissions), permissionSpec(wj.Permissions))
703 if got == nil {
704 return call.Permissions, nil
705 }
706 if asked, ok := domain.PermissionViolations(got, allowed); len(asked) > 0 {
707 what := "The nested job '" + wj.ID + "'"
708 if wj.Permissions == nil {
709 what = "The workflow"
710 }
711 return nil, callFailure{fmt.Sprintf("Error calling workflow '%s'. %s is requesting '%s', but is only allowed '%s'.",
712 call.Call.Uses, what, strings.Join(asked, ", "), strings.Join(ok, ", "))}
713 }
714 return got, nil
715}
716
717// callSecrets reads the "secrets:" of a call: "inherit", or a mapping. Planning
718// validates names only (the expressions were checked against the contexts the
719// schema allows when the file was parsed) and keeps no expression and no value:
720// the mapping is read from the pinned workflow file, as a template token, when
721// a job is assigned (callMapping, mapSecrets), and evaluated once there. Only
722// the plain contexts the expressions read (needs, matrix, strategy, inputs,
723// vars: none is secret) are kept, in call.SecretCtx, as they are now.
724func (rs *runState) callSecrets(wj *workflow.Job, env template.Env, spec calleeSpec, call *domain.CICall) error {
725 if wj.InheritSecrets {
726 call.Inherit = true
727 return nil
728 }
729 if wj.Secrets == nil {
730 return rs.checkRequiredSecrets(spec, map[string]bool{})
731 }
732 call.Secrets = map[string]string{}
733 given := map[string]bool{}
734 for _, p := range wj.Secrets.Pairs {
735 name := strings.ToUpper(p.Key.String())
736 if _, ok := spec.secrets[name]; !ok {
737 return callFailure{"Invalid secret, " + p.Key.String() + " is not defined in the referenced workflow."}
738 }
739 given[name] = true
740 call.Secrets[name] = "" // the name only
741 }
742 plain := map[string]json.RawMessage{}
743 for _, k := range []string{"needs", "matrix", "strategy", "inputs", "vars"} {
744 if v, ok := env.Contexts[k]; ok && v != nil {
745 plain[k] = json.RawMessage(compactJSON(expr.ToJSON(v)))
746 }
747 }
748 b, err := json.Marshal(plain)
749 if err != nil {
750 return err
751 }
752 call.SecretCtx = string(b)
753 return rs.checkRequiredSecrets(spec, given)
754}
755
756// callMapping returns the "secrets:" mapping of a call as the template tokens of
757// the workflow file that holds the calling job: the run's own workflow, or the
758// called workflow of the call that contains it. The tokens come from the file
759// itself and are never rebuilt from text; names are upper-cased.
760func (rs *runState) callMapping(cr *domain.CIJob) (map[string]template.Token, error) {
761 path, yamlText, id := rs.run.WorkflowPath, rs.src.WorkflowYAML, cr.Key
762 if p := rs.scopeOf(*cr); p != nil && p.Call != nil {
763 path, yamlText, id = p.Call.Path, p.Call.YAML, cr.CallID
764 }
765 wf, err := rs.c.flows.parseCalled(path, yamlText)
766 if err != nil {
767 return nil, err
768 }
769 wj := wf.Job(id)
770 if wj == nil {
771 return nil, fmt.Errorf("the calling job is gone")
772 }
773 out := map[string]template.Token{}
774 if wj.Secrets != nil {
775 for _, p := range wj.Secrets.Pairs {
776 out[strings.ToUpper(p.Key.String())] = p.Value
777 }
778 }
779 return out, nil
780}
781
782// secretsReadBy returns the secrets a template token refers to: the expressions
783// of the token are parsed, not the text they produce.
784func secretsReadBy(t template.Token) secretUse {
785 w := &secretWalk{names: map[string]bool{}}
786 template.Walk(t, func(x template.Token) bool {
787 if be, ok := x.(*template.BasicExpression); ok {
788 w.expression(be.Expression)
789 }
790 return true
791 })
792 use := secretUse{All: w.all || w.tooDeep}
793 for n := range w.names {
794 use.Names = append(use.Names, n)
795 }
796 slices.Sort(use.Names)
797 return use
798}
799
800func (rs *runState) checkRequiredSecrets(spec calleeSpec, given map[string]bool) error {
801 for _, name := range sortedKeys(spec.secrets) {
802 if spec.secrets[name] && !given[name] {
803 return callFailure{"Secret " + name + " is required, but not provided while calling."}
804 }
805 }
806 return nil
807}
808
809// chainOf returns the call records a job runs under, innermost first.
810func (rs *runState) chainOf(j domain.CIJob) []*domain.CIJob {
811 var out []*domain.CIJob
812 for sc := rs.scopeOf(j); sc != nil && len(out) <= maxReusableWorkflowDepth; sc = rs.scopeOf(*sc) {
813 out = append(out, sc)
814 }
815 return out
816}
817
818// scopedSecretUse is the secrets of the run's own workflow that a job of a
819// called workflow reads, through the "secrets:" of each call that leads to it.
820// inner is the use inside the called workflow of the job.
821func (rs *runState) scopedSecretUse(j domain.CIJob) (top, inner secretUse) {
822 chain := rs.chainOf(j)
823 inner = secretsUsedBy(chain[0].Call.YAML, j.CallID)
824 cur := inner
825 for _, c := range chain {
826 cur = rs.translateUse(cur, c)
827 }
828 return cur, inner
829}
830
831// translateUse turns the secrets a called workflow reads into the secrets its
832// caller must hold: with "inherit" the same ones, else the secrets that the
833// expressions of the call's mapping, for the ones it reads, refer to (all of
834// them when an expression is not a plain name). A mapping that cannot be read
835// asks for all of them: the safe side for the job, which then gets none it
836// cannot evaluate.
837func (rs *runState) translateUse(use secretUse, cr *domain.CIJob) secretUse {
838 if cr.Call.Inherit {
839 return use
840 }
841 mapping, err := rs.callMapping(cr)
842 if err != nil {
843 return secretUse{All: true}
844 }
845 var out secretUse
846 add := func(t template.Token) {
847 u := secretsReadBy(t)
848 out.All = out.All || u.All
849 for _, n := range u.Names {
850 if !slices.Contains(out.Names, n) {
851 out.Names = append(out.Names, n)
852 }
853 }
854 }
855 for name, t := range mapping {
856 if use.All || slices.Contains(use.Names, name) {
857 add(t)
858 }
859 }
860 slices.Sort(out.Names)
861 return out
862}
863
864// mapSecrets gives a job of a called workflow the secrets it reads, named as the
865// called workflow names them. top holds the secrets of the run's own workflow
866// that it may use (empty for a run that withholds them: the caller decides who
867// gets any). From the outermost call, each mapping value is the template token
868// of the workflow file, evaluated once with the secrets of its calling scope and
869// the plain contexts the call kept; the result is a final string, never parsed
870// again, and is the secrets of the scope below. A secret not passed (not in
871// "secrets:" and no "inherit") is not there. A value that does not evaluate is
872// empty, and nothing of the error is logged: it may hold the secret.
873func (rs *runState) mapSecrets(ctx context.Context, j domain.CIJob, top map[string]string, inner secretUse) map[string]string {
874 chain := rs.chainOf(j)
875 vals := top
876 gh, ghErr := rs.githubValue("", rs.run.Attempt)
877 for i := len(chain) - 1; i >= 0; i-- {
878 cr := chain[i]
879 if cr.Call.Inherit {
880 continue
881 }
882 mapping, merr := rs.callMapping(cr)
883 sd := expr.NewDictionary()
884 for _, k := range sortedKeys(vals) {
885 sd.Add(k, expr.String(vals[k]))
886 }
887 env := template.Env{Contexts: map[string]expr.Value{"secrets": sd}}
888 if ghErr == nil {
889 env.Contexts["github"] = gh
890 }
891 var plain map[string]json.RawMessage
892 _ = json.Unmarshal([]byte(cr.Call.SecretCtx), &plain)
893 for k, raw := range plain {
894 if v, err := expr.FromJSON(raw); err == nil {
895 env.Contexts[k] = v
896 }
897 }
898 next := map[string]string{}
899 for name := range cr.Call.Secrets {
900 t, ok := mapping[name]
901 if merr != nil || !ok {
902 next[name] = ""
903 continue
904 }
905 v, err := template.EvaluateString(ctx, t, env)
906 if err != nil {
907 // Never the error: a format() error holds its arguments.
908 ctxLog(ctx).Warn().Str("secret", name).Msg("ci: a secret passed to a called workflow could not evaluate: it is empty")
909 v = ""
910 }
911 next[name] = v
912 }
913 vals = next
914 }
915 out := map[string]string{}
916 for n, v := range vals {
917 if inner.All || slices.Contains(inner.Names, n) {
918 out[n] = v
919 }
920 }
921 return out
922}
923
924// callHeld reports whether a call holds the concurrency groups it asked for: the
925// group of the calling job and the workflow-level group of the called workflow.
926// The jobs of a call are not planned before.
927func callHeld(call *domain.CIJob) bool {
928 if call.Call == nil || !call.Call.Admitted {
929 return false
930 }
931 if call.Concurrency.Group != "" && !call.Concurrency.FromRun && call.Concurrency.State != domain.CIConcurrencyHolding {
932 return false
933 }
934 return call.Call == nil || call.Call.Concurrency.Group == "" || call.Call.Concurrency.State == domain.CIConcurrencyHolding
935}
936
937// admitCalls lets the calls of a matrix start while the max-parallel of the
938// calling job's strategy allows it, in the order of the legs, as promote does
939// for the jobs that run. A call that is admitted asks for its groups and has its
940// jobs planned; one that is not waits, and starts nothing.
941func (rs *runState) admitCalls() bool {
942 running := map[string]int{}
943 for _, j := range rs.jobs {
944 if j.Call != nil && j.Call.Admitted && !j.Ended() {
945 running[j.Scope+"\x00"+j.Key]++
946 }
947 }
948 changed := false
949 for i := range rs.jobs {
950 j := &rs.jobs[i]
951 if j.Call == nil || j.Call.Admitted || j.Ended() || j.Phase != domain.CIPhaseWaiting {
952 continue
953 }
954 k := j.Scope + "\x00" + j.Key
955 if j.MaxParallel > 0 && running[k] >= j.MaxParallel {
956 continue
957 }
958 call := *j.Call
959 call.Admitted = true
960 j.Call, changed = &call, true
961 running[k]++
962 rs.touch(j)
963 }
964 return changed
965}
966
967// checkJobBudget refuses an expansion that would give the run more job records
968// than a commit may have checks (MaxCIChecks: each record has one check, and the
969// merge rule cannot read more). extra is the number of records the expansion
970// adds (the record of the job being expanded becomes the first of its legs).
971// GitHub's text is the runner's (JobCountValidator.cs); its limit applies to the
972// jobs across all referenced files, and the matrix of one job is capped at 256
973// by ermite-actions (ErrTooManyConfigurations).
974func (rs *runState) checkJobBudget(extra int) error {
975 if len(rs.jobs)+extra > MaxCIChecks {
976 return callFailure{fmt.Sprintf("Workflows may not contain more than %d jobs across all referenced files", MaxCIChecks)}
977 }
978 return nil
979}
980
981// holdCalls asks for the turn of the calls that wait for a group: the group of
982// the calling job first, then the one of the called workflow, so that a call
983// waits in one group at a time. The call record is the entry, and holds both
984// until it ends (releaseEnded).
985func (rs *runState) holdCalls(ctx context.Context) (bool, error) {
986 changed := false
987 for i := range rs.jobs {
988 j := &rs.jobs[i]
989 if j.Call == nil || !j.Call.Expanded || !j.Call.Admitted || j.Ended() || j.Phase != domain.CIPhaseWaiting {
990 continue
991 }
992 entry := domain.CIConcurrencyEntry{RunID: j.RunID, Attempt: j.RunAttempt, JobID: j.ID}
993 if g := j.Concurrency; g.Group != "" && !g.FromRun && g.State != domain.CIConcurrencyHolding {
994 e := entry
995 e.Cancel = g.Cancel
996 st, err := rs.acquire(ctx, g.Group, e)
997 if err != nil {
998 return changed, err
999 }
1000 if st != g.State {
1001 j.Concurrency.State, changed = st, true
1002 rs.touch(j)
1003 }
1004 if st != domain.CIConcurrencyHolding {
1005 continue
1006 }
1007 }
1008 if g := j.Call.Concurrency; g.Group != "" && g.State != domain.CIConcurrencyHolding {
1009 e := entry
1010 e.Cancel = g.Cancel
1011 st, err := rs.acquire(ctx, g.Group, e)
1012 if err != nil {
1013 return changed, err
1014 }
1015 if st != g.State {
1016 call := *j.Call
1017 call.Concurrency.State = st
1018 j.Call, changed = &call, true
1019 rs.touch(j)
1020 }
1021 }
1022 }
1023 return changed, nil
1024}
1025
1026// advanceCalls plans the jobs of called workflows whose needs ended and ends
1027// the call records whose jobs have. It returns at the first change, since a
1028// change reorders the records.
1029func (rs *runState) advanceCalls(ctx context.Context) (bool, error) {
1030 if rs.admitCalls() {
1031 return true, nil
1032 }
1033 if changed, err := rs.holdCalls(ctx); err != nil || changed {
1034 return changed, err
1035 }
1036 for i := range rs.jobs {
1037 rec := rs.jobs[i]
1038 if rec.Scope == "" || rec.Planned || rec.Ended() || rec.Phase != domain.CIPhaseWaiting {
1039 continue
1040 }
1041 call := rs.scopeOf(rec)
1042 if call == nil {
1043 rs.end(&rs.jobs[i], domain.CIFailure, "the call of this job is gone")
1044 return true, nil
1045 }
1046 if call.Ended() {
1047 rs.end(&rs.jobs[i], domain.CICancelled, "")
1048 return true, nil
1049 }
1050 if call.Call == nil || !call.Call.Admitted {
1051 continue // the max-parallel of the calling job holds the call back
1052 }
1053 if !callHeld(call) {
1054 // Waits for the group of its call: shown as pending, as the jobs
1055 // of a run behind a workflow-level group.
1056 if rs.run.Concurrency.Group == "" && rec.Concurrency.State != domain.CIConcurrencyPending {
1057 g := call.Concurrency
1058 if g.Group == "" || g.State == domain.CIConcurrencyHolding {
1059 g = call.Call.Concurrency
1060 }
1061 rs.jobs[i].Concurrency = domain.CIConcurrency{Group: g.Group, State: domain.CIConcurrencyPending, FromRun: true, Evaluated: true}
1062 rs.touch(&rs.jobs[i])
1063 return true, nil
1064 }
1065 continue
1066 }
1067 if rec.Concurrency.FromRun && rs.run.Concurrency.Group == "" {
1068 rs.jobs[i].Concurrency = domain.CIConcurrency{}
1069 rs.touch(&rs.jobs[i])
1070 }
1071 cw, err := rs.calleeOf(call)
1072 if err != nil {
1073 rs.end(&rs.jobs[i], domain.CIFailure, err.Error())
1074 return true, nil
1075 }
1076 cj := cw.Job(rec.CallID)
1077 if cj == nil {
1078 rs.end(&rs.jobs[i], domain.CIFailure, "the called workflow has no job '"+rec.CallID+"'")
1079 return true, nil
1080 }
1081 if len(rs.instancesIn(rec.Scope, rec.CallID)) != 1 {
1082 continue
1083 }
1084 needs, st, ready := rs.needsOf(cw, cj, rec.Scope)
1085 if !ready {
1086 continue
1087 }
1088 rs.planJob(ctx, cw, cj, i, needs, st)
1089 return true, nil
1090 }
1091 for i := range rs.jobs {
1092 call := &rs.jobs[i]
1093 if call.Call == nil || !call.Call.Expanded {
1094 continue
1095 }
1096 var scoped []int
1097 allEnded := true
1098 for k, j := range rs.jobs {
1099 if j.Scope == call.ID {
1100 scoped = append(scoped, k)
1101 allEnded = allEnded && j.Ended()
1102 }
1103 }
1104 if call.Ended() {
1105 changed := false
1106 for _, k := range scoped {
1107 s := &rs.jobs[k]
1108 switch {
1109 case s.Ended():
1110 case s.Active():
1111 rs.requestCancel(s, domain.CICancelUser, endedCallMessage(*call))
1112 default:
1113 rs.end(s, domain.CICancelled, "")
1114 changed = true
1115 }
1116 }
1117 if changed {
1118 return true, nil
1119 }
1120 continue
1121 }
1122 if allEnded {
1123 rs.finishCall(ctx, i, scoped)
1124 return true, nil
1125 }
1126 }
1127 return false, nil
1128}
1129
1130// finishCall ends a call record with the conclusion of its jobs and the outputs
1131// of the called workflow.
1132func (rs *runState) finishCall(ctx context.Context, idx int, scoped []int) {
1133 call := &rs.jobs[idx]
1134 var top []domain.CIJob
1135 for _, k := range scoped {
1136 top = append(top, rs.jobs[k])
1137 }
1138 conc, msg := domain.CISuccess, ""
1139 switch result(top) {
1140 case "failure":
1141 conc = domain.CIFailure
1142 msg = "A job of the called workflow failed"
1143 for _, j := range top {
1144 if j.Conclusion.IsFailure() && !j.ContinueOnError {
1145 msg = "The job '" + j.Name + "' of the called workflow failed"
1146 break
1147 }
1148 }
1149 case "cancelled":
1150 conc = domain.CICancelled
1151 case "skipped":
1152 conc = domain.CISkipped
1153 }
1154 jobs := expr.NewDictionary()
1155 for _, id := range distinctCallIDs(top) {
1156 var insts []domain.CIJob
1157 for _, j := range top {
1158 if j.CallID == id {
1159 insts = append(insts, j)
1160 }
1161 }
1162 outs := expr.NewDictionary()
1163 for _, in := range insts {
1164 for _, k := range sortedKeys(in.Outputs) {
1165 if v := in.Outputs[k]; v != "" || !hasKey(outs, k) {
1166 setKey(outs, k, expr.String(v))
1167 }
1168 }
1169 }
1170 d := expr.NewDictionary()
1171 d.Add("result", expr.String(result(insts)))
1172 d.Add("outputs", outs)
1173 jobs.Add(id, d)
1174 }
1175 outputs := map[string]string{}
1176 spec := readCalleeSpec([]byte(call.Call.YAML))
1177 if len(spec.outputs) > 0 {
1178 gh, err := rs.githubValue(call.Key, rs.run.Attempt)
1179 vars, verr := rs.c.varsContext(ctx, rs.run.Repo)
1180 if err == nil && verr == nil {
1181 env := template.Env{Contexts: map[string]expr.Value{"github": gh, "inputs": expr.NewDictionary(), "vars": vars, "jobs": jobs}}
1182 // The inputs of the call itself.
1183 if v, err := expr.FromJSON([]byte(call.Call.InputsJSON)); err == nil {
1184 env.Contexts["inputs"] = v
1185 }
1186 for _, name := range sortedKeys(spec.outputs) {
1187 v, err := evalInterpolated(ctx, spec.outputs[name], env)
1188 if err != nil {
1189 ctxLog(ctx).Warn().Str("output", name).Msg("ci: an output of a called workflow could not evaluate")
1190 continue
1191 }
1192 outputs[name] = expr.CoerceString(v)
1193 }
1194 }
1195 }
1196 call.Outputs = outputs
1197 rs.end(call, conc, msg)
1198}
1199
1200func distinctCallIDs(js []domain.CIJob) []string {
1201 var out []string
1202 for _, j := range js {
1203 if !slices.Contains(out, j.CallID) {
1204 out = append(out, j.CallID)
1205 }
1206 }
1207 return out
1208}
1209
1210// parseCalled parses a called workflow, once per distinct file.
1211func (w *wfCache) parseCalled(path, yamlText string) (*workflow.Workflow, error) {
1212 sum := sha256.Sum256([]byte(path + "\x00" + yamlText))
1213 key := hex.EncodeToString(sum[:])
1214 w.mu.Lock()
1215 if wf, ok := w.called[key]; ok {
1216 w.mu.Unlock()
1217 return wf, nil
1218 }
1219 w.mu.Unlock()
1220 wf, errs := workflow.Parse(path, []byte(yamlText))
1221 if len(errs) > 0 {
1222 return nil, fmt.Errorf("the called workflow no longer parses: %s", errs[0].Error())
1223 }
1224 w.mu.Lock()
1225 defer w.mu.Unlock()
1226 if len(w.called) >= w.max {
1227 clear(w.called)
1228 }
1229 w.called[key] = wf
1230 return wf, nil
1231}
1232
1233// jobKeysOf are the keys of the "job" context the server knows. A job of a
1234// called workflow names the called workflow in them (workflow_ref,
1235// workflow_sha, workflow_repository, workflow_file_path); github.workflow_ref
1236// and github.workflow_sha stay the caller's (the github context is the
1237// caller's).
1238func (rs *runState) jobKeysOf(ctx context.Context, base domain.CIJob, combo int) ordered {
1239 keys := rs.c.jobKeys(ctx, rs.run, base.Index, combo)
1240 call := rs.scopeOf(base)
1241 if call == nil || call.Call == nil {
1242 return keys
1243 }
1244 c := call.Call
1245 full := c.Repository
1246 if !strings.Contains(full, "/") {
1247 full = rs.c.cfg.Owner + "/" + full
1248 }
1249 ref := c.Ref
1250 if ref == "" {
1251 ref = rs.run.Ref
1252 }
1253 return ordered{
1254 keys[0], {"workflow_ref", full + "/" + c.Path + "@" + ref}, {"workflow_sha", c.Sha},
1255 {"workflow_repository", full}, {"workflow_file_path", c.Path},
1256 }
1257}
1258
1259// endedCallMessage is what the jobs of a call that ended are told: the reason
1260// the call was cancelled for (a newer call of its concurrency group), else that
1261// the calling job ended.
1262func endedCallMessage(call domain.CIJob) string {
1263 if call.Concurrency.Preempted || call.Error != "" {
1264 return call.Error
1265 }
1266 return "Canceled because the calling job ended"
1267}
1268
1269// callResolutions copies the resolutions of the reusable workflows that the
1270// attempt of rs called to the attempt next.
1271func (c *CI) callResolutions(ctx context.Context, rs *runState, next int32) ([]domain.CIActionResolution, error) {
1272 var out []domain.CIActionResolution
1273 seen := map[string]bool{}
1274 for _, j := range rs.jobs {
1275 if j.Call == nil {
1276 continue
1277 }
1278 ref, text := parseCallRef(j.Call.Uses)
1279 if text != "" || ref.local {
1280 continue
1281 }
1282 key := actionRef{owner: ref.owner, repo: ref.repo, ref: ref.ref}.key()
1283 if seen[key] {
1284 continue
1285 }
1286 seen[key] = true
1287 res, err := c.cfg.Store.GetActionResolution(ctx, rs.run.ID, rs.attempt, key)
1288 if errors.Is(err, domain.ErrNotFound) {
1289 continue
1290 }
1291 if err != nil {
1292 return nil, err
1293 }
1294 res.RunAttempt = next
1295 out = append(out, res)
1296 }
1297 return out, nil
1298}