v0.14.0 815fb98

ci_reusable.go RawHistoryFolder

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}

Keyboard shortcuts

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

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

Branches 2

Tags 44

↑↓ moveenter openesc close